Thousands of tutorials recommend ForwardAgent yes. Most of them don't tell you what it actually does to your security posture. Here's the full picture.
You need to SSH from your laptop to a bastion, then from the bastion to an internal server. You've seen the solution in a dozen tutorials:
Host bastion
ForwardAgent yes
It works. It's convenient. And it creates a security hole that could let anyone with root on the bastion impersonate you to every server your key unlocks — for as long as your session is open.
This isn't a theoretical risk. It's a well-documented attack vector with a name: SSH agent hijacking. And the fix — ProxyJump — has been available since 2017 and solves the same problem without the exposure.
This article explains exactly what agent forwarding does under the hood, why it's dangerous, when (if ever) it's acceptable, and how ProxyJump eliminates the need for it in the most common use case.
What SSH Agent Forwarding Actually Does
To understand the risk, you need to understand the mechanism.
The SSH Agent
ssh-agent is a background process that holds your decrypted private keys in memory. When you run ssh host, the SSH client asks the agent to perform cryptographic operations (signing challenges) on its behalf. Your private key never leaves the agent — the client just asks the agent to sign things.
Your SSH client ──── "sign this challenge" ────► ssh-agent
◄─── "here's the signature" ───── ssh-agent
The agent communicates through a Unix socket, stored at a path like /tmp/ssh-agent.XXXXX/agent.12345. The environment variable SSH_AUTH_SOCK points to this socket.
What Forwarding Does
When you SSH to a remote server with ForwardAgent yes, SSH does something specific: it creates a new socket on the remote server that tunnels back to your local agent.
Remote server /tmp/ssh-XXXXX/agent.5678 ──► tunnel ──► Your local ssh-agent
On the remote server, SSH_AUTH_SOCK points to this forwarded socket. Any process on the remote server that connects to this socket gets routed back to your local agent.
This means: from the remote server's perspective, your local agent is present and available. When you run ssh db.internal from the bastion, the bastion's SSH client uses the forwarded socket, which calls back to your local agent, which signs the challenge with your private key.
It feels like your key is on the bastion. It effectively is — for the duration of your session.
The Attack
Here's the problem. Anyone with root access on the remote server can:
- Find all active forwarded agent sockets:
ls /tmp/ssh-*/ - Impersonate any user with a forwarded socket by setting
SSH_AUTH_SOCKto their socket path - Use that socket to authenticate as that user to any server their key unlocks
# An attacker with root on your bastion runs:
ls /tmp/ssh-*/
# Finds: /tmp/ssh-AbCdEf/agent.1234
SSH_AUTH_SOCK=/tmp/ssh-AbCdEf/agent.1234 ssh ubuntu@db.internal
# Connected. As you. Using your key.
The attacker never sees your private key. They don't need to. They use your agent — which does the signing for them.
This works for every server your key can reach, for as long as your SSH session to the bastion remains open. If you leave a session running overnight, that window stays open overnight.
What Makes This Worse
- It requires root on the compromised server. Root access is more common than people expect: a container escape, a sudo misconfiguration, an unpatched privilege escalation vulnerability.
- It's invisible. Your agent doesn't log that it was used. You get no notification.
- It's transitive. If you forward your agent to server A, and server A forwards it to server B, anyone with root on either server can use your agent.
-
It affects all keys in your agent. Not just the key you used to connect — every key loaded in
ssh-agent.
ProxyJump: The Right Solution
ProxyJump was designed specifically for multi-hop SSH. It solves the "connect through a bastion" problem without forwarding your agent.
How ProxyJump Works
Instead of giving the bastion access to your agent, ProxyJump tells your local SSH client to make two connections — both originating from your machine:
Your machine ──TCP──► Bastion:22 ──TCP tunnel──► db.internal:22
(full SSH) (proxied through bastion TCP)
Your SSH client connects to the bastion and immediately asks the bastion to open a TCP connection to the target server. The bastion acts as a dumb TCP proxy — it passes bytes back and forth but has no involvement in the authentication. Your local SSH client handles authentication to both the bastion and the target.
The critical difference: your agent never touches the bastion. The bastion's SSH server forwards TCP traffic; it never performs any authentication on your behalf.
Implementation
Host bastion
HostName bastion.example.com
User ubuntu
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
# Note: No ForwardAgent here
Host db.internal
User ubuntu
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
ProxyJump bastion
That's it. ssh db.internal connects through the bastion, authenticates to both with your local agent, and never forwards the agent to either server.
Multi-Hop
Host deep.internal
ProxyJump bastion,internal-gateway.example.com
Each hop is a TCP proxy. Your local SSH client authenticates to each server in sequence. No agent is forwarded anywhere in the chain.
Files and Commands
ProxyJump works seamlessly with all SSH-based tools:
scp -J bastion file.txt ubuntu@db.internal:/tmp/
rsync -avz -e "ssh -J bastion" local/ ubuntu@db.internal:remote/
sftp -J bastion ubuntu@db.internal
Side-by-Side Comparison
| Agent Forwarding | ProxyJump | |
|---|---|---|
| Mechanism | Forwards agent socket to remote | TCP proxy through intermediate host |
| Authentication location | Remote server calls back to local agent | Local client authenticates everywhere |
| Agent exposure | Agent available on remote server | Agent never leaves local machine |
| Root attack surface | Root on bastion can use your agent | Root on bastion sees TCP bytes only |
| Session window | Risk exists for entire session duration | No persistent risk |
| Multi-hop | Requires forwarding through each hop | Handled natively, comma-separated |
| SCP/rsync support | Works (but risk applies) | Works, no risk |
| Available since | OpenSSH 1.x | OpenSSH 7.3 (2016) |
When Agent Forwarding Might Be Acceptable
Agent forwarding isn't always wrong. There are narrow cases where it's appropriate:
Interactive development servers you fully control
If you're the only user on a server, you own root, and it exists solely as your dev environment — forwarding your agent there is low risk. The threat model requires an attacker to have root on that server; if you are root and the server isn't shared, that threat is remote.
Legacy systems that don't support ProxyJump
OpenSSH 7.3+ supports ProxyJump. Systems running older versions may need ProxyCommand or agent forwarding as a workaround. This should be a migration trigger, not a permanent state.
When you explicitly need the agent on the remote machine
Legitimate use cases: cloning private Git repos on a remote server without copying keys, or running SSH commands from a remote machine that genuinely need to reach a third server you can't reach directly. Even here, minimize the exposure window (connect, do the operation, disconnect).
Mitigations If You Must Use Agent Forwarding
If you genuinely need it, minimize the risk:
# Load only the specific key needed, with a time limit
ssh-add -t 3600 ~/.ssh/id_ed25519_specific # Expires in 1 hour
# Confirm what's in your agent before forwarding
ssh-add -l
# Remove all keys after you're done
ssh-add -D
Use ssh-add -c to require confirmation for every agent operation:
ssh-add -c ~/.ssh/id_ed25519
Every time the forwarded socket is used, you'll get a local prompt asking you to confirm. An attacker exploiting your forwarded agent would trigger this prompt — though if you're away from your machine, you might confirm it without thinking.
Lock down the forwarding to the minimum scope:
# Only forward to a specific, trusted host — not everything
Host trusted-dev-server
HostName 10.0.0.50
ForwardAgent yes
Host *
ForwardAgent no # Default: off
Auditing and Detecting Agent Forwarding
Find Active Forwarded Sockets
On any server you're connected to:
# Your own forwarded socket
echo $SSH_AUTH_SOCK
# All agent sockets (if you have permission)
ls /tmp/ssh-*/
# As root: find all agent sockets across all users
find /tmp -name "agent.*" -type s 2>/dev/null
Check Who's Using Your Agent
There's no built-in logging for agent use, but you can add it with ssh-agent wrappers or tools like ssh-audit-agent. A simpler approach: use hardware security keys (YubiKey, etc.) which require physical touch for each signing operation — a forwarded socket can't silently use the key without your physical presence.
On the Server: Restrict Agent Forwarding
If you administer the bastion or any intermediate server, disable forwarding for users who don't need it:
# /etc/ssh/sshd_config
AllowAgentForwarding no
This is a global disable. If ProxyJump is your standard access pattern, there's no reason for the bastion to accept agent forwarding at all.
Migrating an Existing Setup
If your team currently uses agent forwarding for bastion access, migration to ProxyJump is straightforward and backward-compatible.
Step 1: Add ProxyJump to ~/.ssh/config
Host bastion
HostName bastion.example.com
User ubuntu
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
ForwardAgent no # Explicitly disable
Host *.internal
ProxyJump bastion
User ubuntu
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
Step 2: Verify Target Server Keys Are in known_hosts
With agent forwarding, the bastion performed the second-hop authentication, so only the bastion's key was in your local known_hosts. With ProxyJump, your local client authenticates to every hop, so every server's key needs to be in your local known_hosts.
# Add target server keys through the proxy
ssh-keyscan -J bastion db.internal >> ~/.ssh/known_hosts
Step 3: Disable Agent Forwarding on the Bastion
# /etc/ssh/sshd_config on bastion
AllowAgentForwarding no
sudo systemctl restart ssh
Step 4: Verify
# Connect to target — should work without agent forwarding
ssh db.internal
# Verify agent socket is not present on bastion
ssh bastion "echo \$SSH_AUTH_SOCK"
# Should be empty
The Deeper Principle
The agent forwarding problem illustrates a broader security principle: convenience and security are often in tension, and convenience has a way of winning by default.
Agent forwarding feels seamless precisely because it's transparent. You never see the forwarded socket. You never see it being used. The attack would also be transparent — the attacker uses your agent, achieves their goal, and you see nothing.
ProxyJump is equally transparent from a user experience perspective. ssh db.internal works the same way. The difference is what's happening underneath — and what an attacker on an intermediate server can and cannot access.
When two solutions have the same user-facing behavior and one has a material security advantage, the choice should be obvious. The only reason agent forwarding persists is inertia: it's what tutorials written before 2017 recommended, and those tutorials are still being followed today.
Quick Reference
Replace this:
Host bastion
ForwardAgent yes
With this:
Host bastion
ForwardAgent no
Host *.internal
ProxyJump bastion
And on the bastion server:
# /etc/ssh/sshd_config
AllowAgentForwarding no
That's the migration. It takes five minutes and eliminates an entire category of attack surface.
Follow for more SSH security content. Next: ControlMaster and SSH connection multiplexing for faster deployments.
Top comments (0)