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 4hkeeps the key loaded for four hours, after which the agent forgets it.ssh-add -cmakes the agent demand confirmation (viaSSH_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 web01tunnels your TCP connection through the bastion. The SSH session runs end-to-end between your laptop andweb01, 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 pullon 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 yesmust apply to the host you connect to (the bastion), not the final target. Enabling it in aHost *block forwards your agent everywhere, which is the worst of both worlds. See the~/.ssh/configguide for per-host blocks. SSH_AUTH_SOCKis empty on the remote. Check thatAllowAgentForwardinghasn't been set tonoinsshd_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 withexport 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 pullfails.sudostrips the environment, so the agent socket is lost. Usesudo -E(preservesSSH_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 withssh -t host 'ssh-add -l'to reproduce what a login shell sees.
To learn more
These two pick up where this one stops: