>_C2CZ
Serverless · Self-Hosting · Security · Independent Ops

Could not open a connection to your authentication agent

Could not open a connection to your authentication agent

Kai Tanaka covers SSH, TLS and applied cryptography at C2CZ. Author page · All authors.

Could not open a connection to your authentication agent. is not an SSH error and not a key problem: it is ssh-add reporting that it could not reach a Unix-domain socket, which means the variable it uses to find that socket, SSH_AUTH_SOCK, is missing or wrong in the process that printed it. The message says nothing about your keys, your ~/.ssh/config, or the host you are reaching for, which is why it costs people an afternoon.

Four different situations produce this sentence and three sibling strings, all with exit code 2, and two of them need opposite fixes. Everything below was executed on one arm64 Linux host (Debian 13, OpenSSH_10.0p2, OpenSSL 3.5.6, uid 10000, no systemd); every transcript is the stdout of the exact commands shown above it, captured unedited, and each block was run twice with byte-identical output. The agent throughout is the stock OpenSSH ssh-agent at a fixed socket path so the transcripts stay reproducible.

TL;DR — the four strings, and the one command that separates them

  • The headline message means SSH_AUTH_SOCK is unset in that process — cron, sudo, systemd, docker exec, a GUI launcher. Nothing is listening and nothing was ever asked to.
  • SSH_AGENT_PID is not how clients find the agent. Setting it alone still prints the same message; only the socket path is consulted.
  • Three siblings, all exit 2: Error connecting to agent: No such file or directory (path gone), …: Connection refused (file there, no listener — or a regular file), …: Permission denied (the socket's mode locks you out).
  • One command separates the branches: ssh-add -l exits 0 with identities listed, 1 when the agent is reachable but empty, and 2 when the connection failed. Exit 1 is a working agent; exit 2 is why you are here.
  • SIGTERM and SIGKILL leave different traces. A normally killed agent unlinks its socket (No such file or directory); a kill -9'd one leaves a 600 socket file behind (Connection refused).
  • The socket's own mode is the access control, not its directory: 600 inside a 777 directory still works for you and still refuses a stranger.
  • Stop relying on the variable being inherited. Start the agent at a fixed path and source an idempotent bootstrap that reuses a live agent instead of spawning a second one.

Why the string looks like a network error (and is not)

Three processes are involved and only one of them is SSH. ssh-agent holds the private keys and listens on a Unix-domain socket. ssh-add loads and lists keys through that socket. ssh is another client of the same socket when it authenticates with an agent-held key. The socket's pathname is published in SSH_AUTH_SOCK, and the manual is explicit: “When ssh-agent starts, it creates a Unix-domain socket and stores its pathname in this variable. It is accessible only to the current user, but is easily abused by root or another instance of the same user.”

“Could not open a connection” is a client-side sentence about a filesystem path: connect(2) on a Unix socket failed, and OpenSSH prints a different suffix per reason. Nothing in it involves a remote host, so your firewall, your router and the target's sshd are the wrong direction — the one place a similar string does come from the network stack is a listener bound to the wrong address.

Measured on OpenSSH_10.0p2 / Debian 13, uid 10000. All four failures exit 2.
StringWhat the client hitFirst check
Could not open a connection to your authentication agent.SSH_AUTH_SOCK unset or empty: no connection was attemptedecho "$SSH_AUTH_SOCK"
Error connecting to agent: No such file or directoryconnect() → ENOENT: the path is gone, or the variable is from another sessiontest -e, then start a fresh agent
Error connecting to agent: Connection refusedThe path exists but nothing is listening, or it is not a socket at alltest -S: a regular file fails identically
Error connecting to agent: Permission deniedconnect() → EACCES: the socket's mode denies this userstat -c '%a %U' $SSH_AUTH_SOCK

The state that prints the headline message

Strip the environment back to what cron, a systemd unit or sudo sees and the message reproduces in one command. The second case is the one worth remembering: a leftover SSH_AGENT_PID changes nothing, because the client never reads it to find the socket.

Transcript — the two commands and their output:

$ ssh-add -l            # agent never started in this shell
Could not open a connection to your authentication agent.
exit=2

$ SSH_AGENT_PID=12345 ssh-add -l   # pid set, socket not
Could not open a connection to your authentication agent.
exit=2

Two lines and exit 2, with a different message from the three Error connecting to agent variants. That difference is the fastest triage you have: if the sentence has no prefix, the variable is absent and there is nothing to diagnose. If it has the prefix, the variable is set and pointing somewhere broken.

A lab agent at a fixed path

To reproduce the rest you need a live agent whose socket path does not change between runs, which ssh-agent -a <path> provides. Note where the output redirect sits: ssh-agent prints shell code on stdout for you to eval, and its last line is an echo Agent pid <n>. Redirecting the command substitution itself — not the ssh-agent call inside it — keeps the assignments and silences the pid.

This block creates a throwaway ed25519 key and loads it into a fresh agent; the transcript follows the script.

#!/bin/bash
# Bring one agent up at a fixed path and load one identity.
set -u
mkdir -p /tmp/agent-lab
cd /tmp/agent-lab
rm -f agent.sock key key.pub
ssh-keygen -q -t ed25519 -N '' -C 'lab@c2cz' -f key
export SSH_AUTH_SOCK=/tmp/agent-lab/agent.sock
eval "$(ssh-agent -a "$SSH_AUTH_SOCK")" >/dev/null

echo '$ ssh-add key'
ssh-add key 2>&1
echo "exit=$?"
echo
echo '$ ssh-add -l'
ssh-add -l 2>&1 | sed -E 's|SHA256:[A-Za-z0-9+/=]+|SHA256:<fingerprint>|'
echo "exit=${PIPESTATUS[0]}"
$ ssh-add key
Identity added: key (lab@c2cz)
exit=0

$ ssh-add -l
256 SHA256:<fingerprint> lab@c2cz (ED25519)
exit=0

Identity added: key (lab@c2cz) is the success path, printed on stderr. The listing line is <bits> SHA256:<fingerprint> <comment> (<type>); the fingerprint is replaced with a placeholder here because it changes with every generated key, and an article that quotes a fingerprint verbatim cannot be reproduced.

Branch 2 — the path exists but is not a socket

A stale SSH_AUTH_SOCK is the state after a suspend, a terminal-multiplexer restart, or a copy-pasted env dump from another machine: the variable points at a pathname that no longer holds a listener, or never did.

Transcript — two fixtures, one variable:

$ SSH_AUTH_SOCK=/tmp/agent-lab/gone.sock ssh-add -l   # path does not exist
Error connecting to agent: No such file or directory
exit=2

$ SSH_AUTH_SOCK=/tmp/agent-lab/plain.txt ssh-add -l  # regular file, not a socket
Error connecting to agent: Connection refused
exit=2

The first result is unsurprising. The second is the one that misleads people: a plain empty regular file produces Connection refused, not “not a socket”. The string alone cannot tell you whether the agent died, the variable is stale, or the path was never a socket — stat or a -S test has to answer that, which is why the preflight below tests the file type before it connects.

Branch 3 — the socket exists, the agent is alive, and it still refuses you

This branch produces the third string, and it is a permission failure at connect(2) time: writing to a Unix-domain socket requires write permission on the socket inode. A socket in mode 000 — from a bad umask, a restore that did not preserve modes, or a chmod on the wrong path — is perfectly healthy and completely unusable.

Transcript — the same agent before and after a mode change:

$ stat -c "mode=%a type=%F" /tmp/agent-lab/live.sock
mode=600 type=socket

$ chmod 000 /tmp/agent-lab/live.sock
$ ssh-add -l
Error connecting to agent: Permission denied
exit=2

$ chmod 600 /tmp/agent-lab/live.sock; ssh-add -l
256 SHA256:<fingerprint> lab@c2cz (ED25519)
exit=0

The default is 600, owner-only. Anything that widens it is a bug worth fixing rather than working around: the mode is the only thing separating your agent from every other process on the machine, as the next section shows.

Branch 4 — the socket file outlives the agent

How the agent died decides what the next client sees. ssh-agent removes its socket when it shuts down in an orderly way, so the path disappears and the client reports ENOENT. A kill -9, an OOM kill, or a killed container leaves the socket inode on disk with no listener behind it, and the client reports ECONNREFUSED.

Transcript — the same agent binary, two signals:

$ kill $SSH_AGENT_PID; ls -l /tmp/agent-lab/term.sock   # SIGTERM: agent unlinks its socket
ls: cannot access '/tmp/agent-lab/term.sock': No such file or directory
exit=2

$ kill -9 $SSH_AGENT_PID; stat -c "mode=%a type=%F" /tmp/agent-lab/kill.sock
mode=600 type=socket
$ ssh-add -l
Error connecting to agent: Connection refused
exit=2

That is why “restart your shell” fixes one case and not the other: re-sourcing a shell does not remove a leftover socket inode. The fix is to detect it (-S passes, ssh-add -l exits 2) and unlink the path before starting a new agent, which is what the bootstrap block does. Keeping the socket inside a per-session directory avoids the problem entirely, which is why ssh-agent's own default is a private directory per run.

What a healthy agent looks like

Confirm the agent works at all before changing anything. The three commands below cover the states a preflight will meet: an agent with nothing loaded, an identity on a timer, and the removal of everything.

Transcript — one agent, three states:

$ ssh-add -l            # agent up, zero identities
The agent has no identities.
exit=1

$ ssh-add -t 2 key      # identity with a 2-second lifetime
Identity added: key (lab@c2cz)
Lifetime set to 2 seconds
$ ssh-add -l            # it is loaded, and it is on a countdown
256 SHA256:<fingerprint> lab@c2cz (ED25519)
exit=0

$ sleep 3; ssh-add -l   # the constrained identity has expired
The agent has no identities.
exit=1

$ ssh-add -D            # remove everything from this agent
All identities removed.
exit=0

The agent has no identities. with exit 1 is not an agent failure — it is the state a CI runner is in between ssh-agent and ssh-add. ssh-add -t 2 key is the lifetime flag in its smallest useful form; Lifetime set to 2 seconds confirms the constraint was accepted, and the agent forgets the key on its own, which beats remembering to clean up. Note the asymmetry: ssh-add -D exits 0, ssh-add -l on an empty agent exits 1.

The security boundary: what that socket actually grants

An agent socket is a signing oracle. Anyone who can connect to it can authenticate as you against every host that accepts your keys, without ever seeing a private key. The manual states the design plainly: the socket “is accessible only to the current user, but is easily abused by root or another instance of the same user”. The first half is enforced by the socket's mode; the second half is the part people forget.

Transcript — a socket inside a world-writable directory; the directory mode is irrelevant:

key already exists.
Overwrite (y/n)? $ stat -c "dir  mode=%a type=%F" /tmp/agent-lab/shared
dir  mode=777 type=directory
$ stat -c "sock mode=%a type=%F" /tmp/agent-lab/shared/b.sock
sock mode=600 type=socket
$ ssh-add -l            # reachable from a world-writable directory
256 SHA256:<fingerprint> lab@c2cz (ED25519)
exit=0

The 777 directory neither helps nor hurts: the socket is 600 and the permission check happens on the inode you connect() to. So a socket in a shared directory cannot be protected by the directory — the mode is the whole defence, and anyone who can widen it can hijack the agent. And per the quote above, another process running as the same user is inside the trust boundary by design: a rogue local process, a compromised build step, or a forwarded socket all reach the same oracle.

Forwarding is worth stating plainly because it is a configuration choice. ssh -A or ForwardAgent yes hands the remote host a socket that reaches your local agent, and the manual's warning is not decorative: “Agent forwarding should be enabled with caution. Users with the ability to bypass file permissions on the remote host (for the agent's Unix-domain socket) can access the local agent through the forwarded connection. An attacker cannot obtain key material from the agent, however they can perform operations on the keys that enable them to authenticate using the identities loaded into the agent.” The same manual names the alternative: “A safer alternative may be to use a jump host (see -J).” ProxyJump needs no agent on the remote side at all.

A preflight for CI, cron and systemd units

String matching on Error connecting to agent is not a health check. The script below turns the four branches into six distinct exit codes, so a pipeline can name the cause. It tests the variable, the path, the file type and the mode before it touches ssh-add, and it treats an empty agent as its own condition rather than a transport failure.

This script defines verify-agent.sh; the transcript that follows runs it against seven fixtures:

#!/bin/bash
# Production preflight: verify-agent.sh, exercised on all six exit paths.
set -u
mkdir -p /tmp/agent-lab
cd /tmp/agent-lab
rm -f chk.sock dead.sock plain.txt key key.pub
ssh-keygen -q -t ed25519 -N '' -C 'lab@c2cz' -f key

cat > verify-agent.sh <<'EOS'
#!/bin/bash
# verify-agent.sh — run before any job that needs the SSH agent.
# 0 = usable · 1 = agent up, no identity · 2 = no socket in env · 3 = stale path
# 4 = not a socket · 5 = wrong socket mode · 6 = agent unreachable
set -u
sock=${SSH_AUTH_SOCK:-}
[ -n "$sock" ] || { echo "FAIL 2: SSH_AUTH_SOCK is not set"; exit 2; }
[ -e "$sock" ] || { echo "FAIL 3: $sock does not exist (stale variable)"; exit 3; }
[ -S "$sock" ] || { echo "FAIL 4: $sock exists but is not a socket"; exit 4; }
mode=$(stat -c '%a' "$sock")
case "$mode" in
  600|700) ;;
  *) echo "FAIL 5: $sock has mode $mode, expected 600"; exit 5 ;;
esac
if ! out=$(ssh-add -l 2>&1); then
  case "$out" in
    "The agent has no identities.") echo "FAIL 1: agent reachable, no identity loaded"; exit 1 ;;
    *) echo "FAIL 6: agent unreachable: $out"; exit 6 ;;
  esac
fi
echo "OK: $(printf '%s\n' "$out" | head -n 1 | sed -E 's|SHA256:[A-Za-z0-9+/=]+|SHA256:<fingerprint>|')"
EOS
chmod +x verify-agent.sh

run() { echo "\$ $1"; eval "$1"; echo "exit=$?"; echo; }

export SSH_AUTH_SOCK=/tmp/agent-lab/chk.sock
eval "$(ssh-agent -a "$SSH_AUTH_SOCK")" >/dev/null
run './verify-agent.sh'
ssh-add -q key
run './verify-agent.sh'
chmod 000 /tmp/agent-lab/chk.sock
run './verify-agent.sh'
chmod 600 /tmp/agent-lab/chk.sock
run 'SSH_AUTH_SOCK=/tmp/agent-lab/gone.sock ./verify-agent.sh'
touch plain.txt
run 'SSH_AUTH_SOCK=/tmp/agent-lab/plain.txt ./verify-agent.sh'
run 'env -u SSH_AUTH_SOCK ./verify-agent.sh'
eval "$(ssh-agent -a /tmp/agent-lab/dead.sock)" >/dev/null
kill -9 "$SSH_AGENT_PID"
sleep 1
run 'SSH_AUTH_SOCK=/tmp/agent-lab/dead.sock ./verify-agent.sh'
$ ./verify-agent.sh
FAIL 1: agent reachable, no identity loaded
exit=1

$ ./verify-agent.sh
OK: 256 SHA256:<fingerprint> lab@c2cz (ED25519)
exit=0

$ ./verify-agent.sh
FAIL 5: /tmp/agent-lab/chk.sock has mode 0, expected 600
exit=5

$ SSH_AUTH_SOCK=/tmp/agent-lab/gone.sock ./verify-agent.sh
FAIL 3: /tmp/agent-lab/gone.sock does not exist (stale variable)
exit=3

$ SSH_AUTH_SOCK=/tmp/agent-lab/plain.txt ./verify-agent.sh
FAIL 4: /tmp/agent-lab/plain.txt exists but is not a socket
exit=4

$ env -u SSH_AUTH_SOCK ./verify-agent.sh
FAIL 2: SSH_AUTH_SOCK is not set
exit=2

$ SSH_AUTH_SOCK=/tmp/agent-lab/dead.sock ./verify-agent.sh
FAIL 6: agent unreachable: Error connecting to agent: Connection refused
exit=6

FAIL 1 (agent reachable, no identity) is the only one a correct pipeline should retry, because it usually just means ssh-add has not run yet in that unit. FAIL 5 is the mode case behind the third string in the table, and FAIL 4 catches the regular file that a bare connect() would report as Connection refused. Branch on the exit codes, not the text.

The fix that survives a new shell

On a laptop, run one agent and reuse it. On a server, cron, sudo and unit files are exactly the processes that do not inherit your login environment, so assuming SSH_AUTH_SOCK is present is the bug. The bootstrap below is meant to be sourced from ~/.bashrc or ~/.zshrc: it starts an agent only when the exit code says nothing is reachable, and reuses the running one otherwise.

Sourced twice in two fresh shells:

#!/bin/bash
# The fix that survives a new shell: an idempotent bootstrap for .bashrc/.zshrc.
set -u
mkdir -p /tmp/agent-lab/boot
cd /tmp/agent-lab
rm -f boot/agent.sock key key.pub
ssh-keygen -q -t ed25519 -N '' -C 'lab@c2cz' -f key

cat > agent-boot.sh <<'EOS'
#!/bin/bash
# agent-boot.sh — SOURCE this from .bashrc/.zshrc. Safe to run on every shell.
SOCK="/tmp/agent-lab/boot/agent.sock"
export SSH_AUTH_SOCK="$SOCK"
ssh-add -l >/dev/null 2>&1
case $? in
  0|1) ;;                    # 0 = identity loaded, 1 = agent up and empty: reuse it
  *)  rm -f "$SOCK"          # 2 = nothing reachable: start a fresh agent
      eval "$(ssh-agent -a "$SOCK")" >/dev/null ;;
esac
[ -f key ] && ssh-add -q key 2>/dev/null
EOS
chmod +x agent-boot.sh

echo '$ . ./agent-boot.sh   # first shell: starts an agent and loads the key'
. ./agent-boot.sh
ssh-add -l 2>&1 | sed -E 's|SHA256:[A-Za-z0-9+/=]+|SHA256:<fingerprint>|'
a=$(stat -c '%i' /tmp/agent-lab/boot/agent.sock)
echo
echo '$ . ./agent-boot.sh   # second shell: must NOT start a second agent'
. ./agent-boot.sh
b=$(stat -c '%i' /tmp/agent-lab/boot/agent.sock)
[ "$a" = "$b" ] && echo "socket inode unchanged: the same agent was reused"
$ . ./agent-boot.sh   # first shell: starts an agent and loads the key
256 SHA256:<fingerprint> lab@c2cz (ED25519)

$ . ./agent-boot.sh   # second shell: must NOT start a second agent
socket inode unchanged: the same agent was reused

The decision rule is the exit code, not the message: 0 and 1 both mean a live agent, and only 2 justifies rm -f plus a new ssh-agent. That rm -f is what clears the orphan socket from a kill -9'd agent; without it, ssh-agent -a fails to bind and you are back to Connection refused. On a machine that must never inherit the variable, start the agent once at a fixed path (a systemd user unit running ssh-agent -a %t/ssh-agent.socket, an s6 service, or a container entrypoint) and let every job read that path from its own environment. That configuration is the only part of this article that is quoted rather than executed, because this host runs no systemd.

The first two minutes, in order

  1. echo "${SSH_AUTH_SOCK:-unset}". Unset → you are in the headline case; start an agent.
  2. ls -l "$SSH_AUTH_SOCK". Missing → stale variable from a dead agent; present but not a socket → someone exported a path that never was one.
  3. stat -c '%a %U' "$SSH_AUTH_SOCK". Not 600, or not owned by you → permissions before anything else.
  4. ssh-add -l. Exit 0 → the agent is fine and the problem is elsewhere, such as a private key the client refuses to load or a connection that dies before authentication begins. Exit 1 → the agent is fine and empty; run ssh-add. Exit 2 → the socket is broken, and the suffix says which way.
  5. Only then look at the remote side. This message cannot come from a remote host.

The pattern behind all four branches is the pattern behind every Unix socket treated as a file path: the pathname is configuration, the inode is truth, and the mode on that inode is the access control.

C2CZ

Hyper-specific engineering guides: Cloudflare Workers, Turso, self-hosting, network security, offensive security, independent operations.