>_ PocketShell Sign in

SSH Agent Forwarding: Setup, Risks, and Safer Paths

SSH into a bastion host and try to git pull or hop to a second server from there, and you hit the problem agent forwarding solves: your private key lives on your laptop, while the machine you're on now needs to authenticate somewhere else. Copying your key to the bastion is the wrong answer. Let the remote host borrow your local agent instead, carefully, and with an understanding of what you're actually handing over.

The SSH agent's job

ssh-agent is a small background process that holds decrypted private keys in memory. When you connect to a host, the SSH client asks the agent to perform a signing operation. The key never leaves the agent's memory and never touches disk while it's loaded. The client finds the agent through the SSH_AUTH_SOCK environment variable, which points at a Unix domain socket.

Load keys with ssh-add:

ssh-add                      # adds the default identities (~/.ssh/id_ed25519, etc.)
ssh-add -l                   # list loaded keys (fingerprints)
ssh-add -d ~/.ssh/id_ed25519 # remove a specific key
ssh-add -D                   # remove all keys

Two options make good habits:

  • ssh-add -t 4h keeps the key loaded for four hours, after which the agent forgets it.
  • ssh-add -c makes the agent demand confirmation (via SSH_ASKPASS) before every signing operation, so a rogue process can't silently use your key.

You can also skip manual ssh-add entirely.

Since OpenSSH 7.2, this in ~/.ssh/config loads a key into the agent the first time you use it:

Host *
    AddKeysToAgent yes

Since OpenSSH 8.9 the directive also accepts a lifetime, so AddKeysToAgent 8h adds keys on use and expires them after eight hours.

Enabling agent forwarding

Forwarding is off by default. Enable it per host in ~/.ssh/config:

Host bastion
    HostName bastion.example.com
    User alexey
    ForwardAgent yes

or once, on the command line: ssh -A bastion. Prefer the config entry. ssh -A is easy to fire off reflexively, and a per-host ForwardAgent yes keeps forwarding off for every host you haven't consciously enabled it for. When you connect, the client asks the remote sshd to create a new Unix socket on that host (usually under /tmp/ssh-XXXXXX/agent.<pid>), tunnels it back to your local agent, and sets SSH_AUTH_SOCK on the remote side to point at it.

Verify from inside the remote session:

echo "$SSH_AUTH_SOCK"
# /tmp/ssh-QXth4z8BhR/agent.21431

ssh-add -l
# 256 SHA256:AbCd... alexey@laptop (ED25519)

If ssh-add -l shows your local fingerprints on the remote host, forwarding works: any connection from that host to a third machine can now authenticate as you.

Forwarding exposes your agent socket

Agent forwarding doesn't copy your key, but it exposes your agent's socket on the remote host. Anyone who can open that socket (root most obviously, and any process running as your user) can ask your agent to sign authentication requests. They never see the key, but for as long as the forwarded socket exists they can impersonate you on every other host your key can reach.

This is the standard caveat, and it deserves emphasis: only forward your agent to machines you trust as much as your own laptop. A shared build box, a university login server, or any multi-tenant bastion is a poor place for ForwardAgent yes. A compromised intermediary can also keep the hijacked agent usable beyond your logout by holding an open connection to the socket.

If you like the idea of a key that never has to exist anywhere beyond the machine in front of you, PocketShell, a browser-based SSH client, works the same way. Keys never sync at all: you enter one per host, it's stored encrypted in that one browser, and it's used in memory when you connect.

Safer forwarding with destination constraints

OpenSSH 8.9 added a middle ground: destination constraints.

When you add a key, you declare which hops the agent may ever use it for, even if the socket is stolen:

ssh-add -h bastion.example.com \
        -h 'bastion.example.com>deploy@web01.internal' \
        ~/.ssh/id_ed25519

The first constraint allows the origin-to-bastion hop. The second, in src>dst form, allows the key to be used through the bastion to reach web01.internal as deploy, and nowhere else. If someone on the bastion grabs the socket and points it at an unrelated server, the agent refuses to sign. Adding -t (a lifetime) shrinks both the blast radius and the window. It requires no changes on the server side, only a modern client, and OpenSSH 8.9 is more than four years old now.

Skipping forwarding entirely

Before you type -A, check whether a newer tool removes the need:

  • ProxyJump (OpenSSH 7.3+). For host-to-host hopping, ssh -J bastion web01 tunnels your TCP connection through the bastion. The SSH session runs end-to-end between your laptop and web01, and the bastion only relays encrypted bytes. It never sees your key or your agent. This is the default answer for "I need to reach an internal server via a jump host".
  • Deploy keys and machine users. For git pull on a server, a deploy key for a single repository is far better than your personal identity. Git hosting providers all support them, and a leaked deploy key is much easier to contain.
  • Short-lived certificates. Organizations running an SSH certificate authority issue keys that expire in hours. The agent's value drops when every key is ephemeral anyway.

Use ProxyJump for connectivity, agent forwarding only when a remote-originated connection genuinely must authenticate as you, and destination constraints whenever it does.

Troubleshooting: common pitfalls

These five pitfalls cover most forwarding problems:

  • Forwarding enabled on the wrong hop. ForwardAgent yes must apply to the host you connect to (the bastion), not the final target. Enabling it in a Host * block forwards your agent everywhere, which is the worst of both worlds. See the ~/.ssh/config guide for per-host blocks.
  • SSH_AUTH_SOCK is empty on the remote. Check that AllowAgentForwarding hasn't been set to no in sshd_config, and that the remote shell's rc files aren't overwriting the variable.
  • tmux on the remote has stale sockets. Panes started under an earlier login keep that login's SSH_AUTH_SOCK, which disappears when the session ends. For shells you open afterward, publish the current socket globally right after connecting: tmux set-environment -g SSH_AUTH_SOCK "$SSH_AUTH_SOCK". In an already-running pane, re-point it at the newest socket with export SSH_AUTH_SOCK=$(ls -t /tmp/ssh-*/agent.* 2>/dev/null | head -1). More on this in tmux sessions that survive SSH disconnects.
  • sudo git pull fails. sudo strips the environment, so the agent socket is lost. Use sudo -E (preserves SSH_AUTH_SOCK), or better, check out with the deploy key of the user you're sudoing into.
  • Works interactively, fails in scripts. Non-interactive SSH sessions may not source the profile that sets SSH_AUTH_SOCK. Test with ssh -t host 'ssh-add -l' to reproduce what a login shell sees.

To learn more

These two pick up where this one stops: