Server SecurityGrid Pulsar team··10 min read

SSH Hardening When an AI Agent Also Has a Key

Harden SSH on Ubuntu 24.04 LTS with separate human and automation keys, a restricted agent account, safe reloads, and a practical key revocation drill.

SSH Hardening When an AI Agent Also Has a Key featured image
On this page
Short answer

You will have key-only SSH access for your administrator and a separate automation account whose key can run only explicitly approved commands. Keep a recovery session open, verify your human key, then restrict the agent with a root-owned command wrapper. Validate the effective SSH configuration before reloading, test rejected operations as well as successful ones, and rehearse revoking the automation key without affecting your own access.

1. Keep a recovery path open

Start on a single VPS running Ubuntu 24.04 LTS, using an existing non-root account with sudo access. Keep that SSH session open throughout the procedure and confirm that you can reach the provider's recovery console. If root is your only working login, first create and verify a separate administrator through the initial Ubuntu server setup.

The client examples use ubuntu for your existing administrator and 203.0.113.10 for the server address. Replace those values on your laptop; the address is reserved for documentation. The new account named deploy will initially have permission only to report uptime and disk usage, despite its name.

Back up the SSH configuration before editing it. The backup directory is accessible only to root because the copied directory also contains server host keys. Record the printed path somewhere outside the server.

bashTested on Ubuntu 24.04
ssh_backup_dir=$(sudo mktemp -d /root/ssh-before-hardening.XXXXXX)
sudo cp -a /etc/ssh "$ssh_backup_dir/"
printf '%s\n' "$ssh_backup_dir"
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
Check: You have a backup path and the server's Ed25519 host-key fingerprint. The recovery console opens successfully, and your existing administrator session remains connected.

Do not change the SSH port or firewall rules during this procedure. Keeping the existing network path makes authentication failures easier to diagnose. Handle broader firewall policy separately with the Ubuntu hardening checklist.

2. Separate the human and automation credentials

Create a dedicated administrator key on your laptop, with a passphrase. If you already have a dedicated, passphrase-protected administrator key, use it instead of generating another. These commands run on your laptop and can prompt for input.

bash
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keygen -t ed25519 -f ~/.ssh/vps-human -C human-admin
ssh-copy-id -i ~/.ssh/vps-human.pub [email protected]
ssh -o IdentitiesOnly=yes -o IdentityAgent=none -o PreferredAuthentications=publickey -i ~/.ssh/vps-human [email protected]
Check: A new administrator session opens using the human key, without asking for the remote account password. Inside that session, run sudo -v and confirm that your administrator can still obtain sudo access.

If ssh-copy-id cannot authenticate using your existing access, append the human public key through your already-open administrator session. Preserve existing authorized keys while doing this. A prompt for the local key passphrase is expected and is different from a server password prompt.

Run Claude Code under a separate local operating-system identity or on a dedicated runner that cannot read your administrator private key. A second key filename under the same unrestricted local account does not create an access boundary. Keep your administrator's unlocked ssh-agent socket outside that runner as well; the Claude Code access model guide helps you choose where the agent runs.

Under that separate client identity, generate the automation key below. Prefer a passphrase with a dedicated local ssh-agent that you unlock for supervised work. For unattended operation, an unencrypted key is a deliberate tradeoff: protect its file and keep its server permissions narrow.

bash
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keygen -t ed25519 -f ~/.ssh/vps-agent -C agent-ops
Check: The automation identity has its own private key and public key. It cannot read the human private key or access the human administrator's authentication socket.

3. Create an account with a fixed command allowlist

Create deploy as a new account without sudo access. If an account with that name already exists, stop and choose a new dedicated name throughout this procedure instead of repurposing it. We start with observation commands because a general remote shell gives the agent every permission available to its Unix account.

bashTested on Ubuntu 24.04
sudo adduser --disabled-password --gecos '' --home /var/lib/agent-ssh-deploy --no-create-home --shell /bin/sh deploy
sudo install -d -o root -g root -m 755 /var/lib/agent-ssh-deploy
sudo install -d -o root -g root -m 755 /usr/local/libexec
sudo install -d -o root -g root -m 755 /etc/ssh/authorized_keys
sudo touch /etc/ssh/authorized_keys/deploy
sudo chown root:root /etc/ssh/authorized_keys/deploy
sudo chmod 644 /etc/ssh/authorized_keys/deploy
id deploy
Check: id deploy shows the dedicated account without membership in sudo, docker, or another privileged group. Its home directory and authorized-key file are owned by root.

Install this wrapper as root. It compares the complete requested command against exact strings and never evaluates client-supplied shell text. The root-owned home and /bin/sh login shell also avoid relying on agent-writable shell startup files.

bashTested on Ubuntu 24.04
sudo tee /usr/local/libexec/agent-ssh >/dev/null <<'EOF'
#!/bin/sh
set -eu
case "${SSH_ORIGINAL_COMMAND-}" in
  uptime)
    exec /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /usr/bin/uptime
    ;;
  disk)
    exec /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C /usr/bin/df -h /
    ;;
  *)
    printf '%s\n' 'Denied: command is not approved' >&2
    exit 126
    ;;
esac
EOF
sudo chown root:root /usr/local/libexec/agent-ssh
sudo chmod 755 /usr/local/libexec/agent-ssh
sudo -u deploy env SSH_ORIGINAL_COMMAND=uptime /usr/local/libexec/agent-ssh
sudo -u deploy env SSH_ORIGINAL_COMMAND=disk /usr/local/libexec/agent-ssh
Check: The wrapper prints uptime and filesystem usage when run as deploy. Both commands finish without requesting sudo privileges for that account.

Keep deployment and service mutation out of this initial wrapper. Adding unrestricted sudo, a container-management socket, or permission to edit a privileged service's executable would change the account's authority substantially. Use the production agent guardrails when designing an approved operation that changes the server.

4. Bind the automation key to the restricted account

Using your human administrator session, edit /etc/ssh/authorized_keys/deploy with sudoedit. Paste the complete contents of the automation client's vps-agent.pub, prefixed by restrict and a space, on a single line. The public key may be readable by the account; the account must not be able to rewrite it.

The restrict key option disables forwarding and PTY allocation, among other session features. It does not itself restrict which commands the user can execute. That distinction is documented in the OpenSSH authorized-key reference, and it is why this procedure also forces a command wrapper.

Append the following block at the very end of /etc/ssh/sshd_config, using sudoedit. Keeping it there avoids accidentally placing later global configuration inside a user-specific match. If the file already contains matching rules for deploy, resolve them before proceeding.

conf
Match User deploy
    AuthorizedKeysFile /etc/ssh/authorized_keys/deploy
    AuthenticationMethods publickey
    ForceCommand /usr/local/libexec/agent-ssh
    DisableForwarding yes
    PermitTTY no
    PermitTunnel no
    PermitUserRC no
    PermitUserEnvironment no
Check: The installed public-key line begins with restrict, and the account-specific configuration names the root-owned key file and wrapper. Neither file is writable by deploy.

The forced command applies even if someone later installs another key for this account. It also blocks ordinary SFTP and SCP sessions because those requests are not on the allowlist. Keep this account dedicated to SSH automation rather than running unrelated services under it.

5. Require public keys for new SSH logins

Proceed only after the separate human key login and sudo check succeeded. Use sudoedit to create /etc/ssh/sshd_config.d/00-local-hardening.conf with the following contents. These settings disable direct root login and require public-key authentication for new connections.

conf
PubkeyAuthentication yes
AuthenticationMethods publickey
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
PermitEmptyPasswords no
AllowAgentForwarding no
X11Forwarding no
PermitUserEnvironment no
LogLevel VERBOSE

Ubuntu normally includes the drop-in directory near the beginning of its SSH configuration. Most OpenSSH settings use the first value encountered, so a late override file may do nothing. Check the effective values in the next step rather than trusting the filename; see Ubuntu's OpenSSH configuration documentation.

Check: The drop-in exists with the intended settings, and your original session remains open. No reload has occurred yet, so configuration mistakes can still be corrected before new connections use it.

Disabling SSH passwords does not remove your administrator's local password or prevent sudo from asking for it. Leave the distribution's cryptographic algorithm defaults alone unless you have a specific compatibility requirement. Keeping OpenSSH updated is more useful than copying an old cipher list from an unrelated server.

6. Inspect effective settings before reloading SSH

Syntax validation catches malformed configuration, while effective-configuration inspection reveals which rules actually win. Run both checks before reloading. The connection addresses below are examples for evaluating rules; repeat with your actual connection details if you have address-dependent matches.

bashTested on Ubuntu 24.04
sudo /usr/sbin/sshd -t
sudo /usr/sbin/sshd -T -C user=ubuntu,host=admin-client,addr=198.51.100.20,laddr=203.0.113.10,lport=22 | grep -E '^(authenticationmethods|pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication|permitrootlogin|allowagentforwarding|forcecommand) '
sudo /usr/sbin/sshd -T -C user=deploy,host=agent-client,addr=198.51.100.30,laddr=203.0.113.10,lport=22 | grep -E '^(authenticationmethods|authorizedkeysfile|forcecommand|disableforwarding|permittty|permittunnel|permituserrc|permituserenvironment) '
Check: Syntax validation prints nothing. For the administrator, authentication is publickey, password and keyboard-interactive authentication are no, root login is no, and forcecommand is none. For deploy, the key file and forced command match your installed paths, disableforwarding is yes, and the remaining listed permissions are no.

If any value differs, inspect earlier drop-ins and existing Match blocks before continuing. ForceCommand restricts command execution, while DisableForwarding separately closes forwarding features, as specified in the OpenSSH server configuration reference. Once the effective values are correct, reload the service.

bashTested on Ubuntu 24.04
sudo /usr/sbin/sshd -t && sudo systemctl reload ssh.service
Check: The reload command completes successfully and your existing administrator session stays connected. Keep it open until fresh client connections pass the next step.

If new logins fail, use the retained session or recovery console to move 00-local-hardening.conf outside the included directory and restore sshd_config from the recorded backup. Run syntax validation again before reloading. Do not restore the entire SSH directory blindly when restoring these two configuration changes is sufficient.

7. Verify allowed commands and denied access

First repeat the human key login from your laptop and verify sudo in that new session. Then establish the automation client's host trust using the command below, comparing the presented fingerprint with the value obtained through the recovery console. Never bypass a changed fingerprint merely to get automation running.

bash
ssh -o StrictHostKeyChecking=ask -o IdentitiesOnly=yes -i ~/.ssh/vps-agent [email protected] uptime
Check: The independently verified host fingerprint matches, and the automation key returns uptime. If authentication fails, inspect the SSH journal from your administrator session instead of relaxing the restrictions.

Add this host entry to the automation identity's ~/.ssh/config. If its private key has a passphrase, load it into that identity's dedicated local ssh-agent before the batch checks. Disabling connection sharing ensures the checks establish new authenticated connections.

conf
Host agent-vps
    HostName 203.0.113.10
    User deploy
    IdentityFile ~/.ssh/vps-agent
    IdentitiesOnly yes
    BatchMode yes
    StrictHostKeyChecking yes
    ForwardAgent no
    ControlMaster no
    ControlPath none

Run the following probes individually from the automation client. Failures are expected for the forbidden requests, so do not put these commands into a script that stops at the first nonzero exit. The command containing a semicolon checks that the wrapper rejects shell text instead of executing it.

bash
ssh agent-vps uptime
ssh agent-vps disk
ssh -T agent-vps id
ssh -T agent-vps 'uptime; id'
ssh -T agent-vps
ssh -o ExitOnForwardFailure=yes -R 0:localhost:22 agent-vps uptime
Check: uptime and disk succeed. Requests for id, combined shell text, and an empty command print Denied: command is not approved; the remote-forwarding request fails with a forwarding error.

Finally, confirm that a client offering only password-style authentication cannot log in. A successful human key login proves availability, while this separate failure proves the old authentication path is closed. Keep the recovery session until both results are clear.

bash
ssh -o ControlPath=none -o PubkeyAuthentication=no -o PreferredAuthentications=password,keyboard-interactive [email protected]
Check: The password-only attempt ends with authentication denied, while a fresh human key connection still succeeds. You can now close the original recovery SSH session.

8. Rehearse revocation before scheduling automation

Before handing this connection to a scheduled Claude Code task, rehearse removing its access. From your human session, use sudoedit to place # at the beginning of the automation key line in /etc/ssh/authorized_keys/deploy. Save the file and try a fresh ssh agent-vps uptime connection.

Check: The new automation connection fails authentication, while your separate human key still works. Authorized-key changes take effect for new authentication attempts without an SSH service reload.

Remove the comment marker to restore this rehearsal key, then verify uptime again. During an actual credential compromise, install a newly generated public key instead of re-enabling the old one. Removing a key does not terminate existing authenticated connections, so inspect and end remaining deploy sessions through your human account.

Record the key fingerprint with its owner and intended use in your operations notes. Inspect authentication events after the drill using the journal command below; verbose authentication logging helps connect a login to a key fingerprint. It does not provide a complete record of every remote command.

bashTested on Ubuntu 24.04
sudo journalctl -u ssh.service --since '30 minutes ago' --no-pager
Check: The journal contains the recent authentication attempts, including accepted public-key events with fingerprints. Match the automation fingerprint to your recorded key rather than relying only on its editable comment.

Now give Claude Code one bounded task: connect through agent-vps, request disk, and report the result without changing the server. Keep the command allowlist in the task instructions for clarity, while treating the server configuration as the enforcement boundary. Add another operation only after reviewing exactly what the wrapper would execute and which files that operation can affect.

FAQ

What is the safest SSH key setup for automation?

Use a dedicated key for each automation identity and keep it separate from human administrator credentials. Give the receiving account only the permissions its task needs, preferably through a forced command. A key identifies a caller; it does not limit that caller's authority. Protect the private key on the client and make revocation independent of your own login.

Should an SSH key for automation have a passphrase?

Use a passphrase when a person can unlock a dedicated authentication agent before work begins. Unattended jobs need some way to access signing authority, which may mean an unencrypted key or a credential service. A passphrase stored beside the key offers little separation. For a small setup, strict server-side permissions and an isolated runner are essential whichever unlocking method you choose.

Does the authorized_keys restrict option prevent shell access?

No. It disables several SSH session capabilities, including PTY allocation and forwarding, but a client can still request commands unless another restriction applies. Combine it with a forced command that accepts only explicitly defined operations. Avoid wrappers that pass the requested text to a shell, since that turns an apparent allowlist back into arbitrary command execution.

Can Claude Code use my existing administrator SSH key?

It can technically use credentials available to its process, but we recommend a dedicated automation identity. If Claude Code can read your administrator key or use its unlocked authentication socket, server restrictions attached to another key do not protect that administrator identity. Separate the client environment as well as the remote account, and keep privileged approvals in your human session.

Do I still need Fail2ban after disabling SSH passwords?

You may use Fail2ban to reduce repeated authentication attempts and their log noise, but it does not constrain an agent holding a valid key. Prioritize account permissions and a working revocation procedure first. Add banning when your operational needs justify it, with recovery access available so an incorrect rule cannot leave you dependent on the blocked connection.

Free ebook

Put this guide in the bigger picture

See how this topic connects to the rest of your product. Our free field guide explains apps, infrastructure and AI, with checklists to help you make business decisions.

We use cookies to ensure that we give you the best experience on our website. If you continue to use this site we will assume that you are happy with it.