SSH host verification for agents that touch production
SSH host verification for autonomous coding agents: pin fingerprints, scope remote accounts, and review commands before damage spreads.

An autonomous coding agent should never decide for itself that a new SSH server is trustworthy. It can inspect a repository, draft a deployment, and request access, but it should stop when the remote identity does not match a record established by a human or a trusted provisioning process.
SSH gives you two separate security questions: "Did I reach the intended server?" and "What may this account do on that server?" Teams routinely mash those together because both use public keys. That mistake turns a misdirected connection into a credential exposure, then turns an overly broad account into a production incident.
The practical design is plain: pin host identity before the agent connects, give every agent task a narrow remote account, and make a human review commands whose effect cannot be safely inferred. Each layer limits a different failure. None substitutes for the others.
Host identity must be checked before user authentication
Host verification answers whether the SSH client reached the server it intended to reach. User authentication answers whether that server accepts the account credential offered by the client. The ordering matters because SSH negotiates and verifies the server host key before the client sends user authentication material.
A host key belongs to the server, not to the administrator and not to the agent. The fingerprint is a short representation of that public host key, commonly shown in SHA256 form. If deploy.example.internal normally presents one fingerprint and suddenly presents another, the client has evidence that something changed. It might be a legitimate rebuild. It might also be a DNS error, a reused address, a bastion mistake, or an active interception attempt.
Consider a coding agent told to run a migration on db-prod.internal. Its SSH configuration resolves that name to an address. An attacker who can influence DNS, a proxy route, or a stale inventory entry can direct the connection to a server under their control. If the client accepts the unfamiliar host key, that server can ask for user authentication. A client-side SSH private key may not leave the client, but agent access often includes passwords, certificate signing flows, forwarding, or commands that disclose useful information after login. More importantly, the agent may now run its intended command on the wrong machine.
A pinned host fingerprint causes the connection to fail before the agent can treat the machine as its target. That is why an unfamiliar or changed host is an authorization boundary, not a minor warning to suppress.
Host verification does not confirm that a machine is healthy, correctly configured, or safe to modify. It only confirms continuity of cryptographic identity. That limit is useful. Do not ask a host fingerprint to decide whether rm -rf, a schema migration, or a firewall change makes sense.
Trust on first use is a poor fit for unattended work
Trust on first use is tolerable for a developer connecting manually to a disposable personal machine. It is a poor default for an autonomous process because the first connection is exactly when someone must decide whether the hostname, route, and fingerprint belong together.
OpenSSH documents this choice in the ssh_config manual under StrictHostKeyChecking. With yes, the client never adds unknown host keys automatically and refuses a changed key. With accept-new, it records an unknown key automatically but still refuses a changed one. With no or off, it accepts more cases that deserve operator attention.
accept-new is often sold as a sensible compromise. It does reduce churn when hosts are created frequently. It also grants a network path the right to establish the initial identity record. For an agent that can alter infrastructure, that is the wrong party making the decision.
Use StrictHostKeyChecking=yes for agent-operated production and staging endpoints. When a connection fails because the host is unknown, send the agent's request to a person who can compare the reported fingerprint against a source outside that SSH connection. A cloud console, a signed inventory record, a physical console, or an existing management channel can provide that comparison.
Do not solve the interruption by placing StrictHostKeyChecking=no in a global configuration file. That line tends to outlive the temporary incident that justified it, then quietly applies to hosts nobody meant to loosen.
There is a narrower exception: short-lived test infrastructure whose host identities come from a provisioning system that can publish an authenticated host list before the test begins. The agent still should not learn a host identity from its own first network contact. The trust source moved; it did not disappear.
A fingerprint is only useful when its source is independent
A fingerprint copied from the endpoint you are trying to verify proves almost nothing. ssh-keyscan is convenient for collecting public host keys, and that convenience creates a familiar trap: an operator runs it against a hostname, pastes the result into known_hosts, and calls the host verified. If DNS or routing already points at an attacker, they pinned the attacker's key.
Use ssh-keyscan as a collection command after you have an independent fingerprint, not as the source of trust. For example, an administrator can retrieve a host key fingerprint from a provider console or from a signed build record, then compare it with the collected key.
ssh-keyscan -t ed25519 app-prod.internal > /tmp/app-prod.hostkey
ssh-keygen -lf /tmp/app-prod.hostkey -E sha256
The second command prints output in this shape:
256 SHA256:exampleFingerprintMaterial app-prod.internal (ED25519)
Compare the SHA256: value character for character with the independently obtained value. Also verify the algorithm. If the record says ED25519 and the collected result is RSA, stop and investigate rather than treating either result as interchangeable.
Then pin the public key, not merely a note containing its fingerprint. A dedicated file keeps agent targets separate from a developer's personal accumulation of old machines:
app-prod.internal ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExamplePublicHostKeyMaterial
[192.0.2.44]:22 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExamplePublicHostKeyMaterial
Include both the canonical hostname and any address form that the agent is permitted to use. Otherwise, a workflow that uses an address one day and a name the next can fall back to a new trust decision. Keep the file under controlled configuration management, with a change review that identifies the old and replacement fingerprint.
DNS SSHFP records can help where DNSSEC validation is correctly deployed end to end. They do not rescue an agent configuration that accepts ordinary unsigned DNS answers as proof. Treat SSHFP as an additional verified publication channel, not a decorative record that makes blind first use safe.
Pin the route and host record in the SSH configuration
An agent needs an SSH configuration that removes ambiguity rather than one that inherits a workstation's habits. Pin the destination name, its expected host-key file, its account, and the connection behavior in an explicit entry.
Host app-production
HostName app-prod.internal
User agent_release
Port 22
UserKnownHostsFile ~/.ssh/agent_known_hosts
GlobalKnownHostsFile /dev/null
StrictHostKeyChecking yes
UpdateHostKeys no
PasswordAuthentication no
KbdInteractiveAuthentication no
ForwardAgent no
PermitLocalCommand no
This fragment prevents several ordinary mistakes. UserKnownHostsFile avoids a surprise dependency on whatever host entries a human has accumulated. GlobalKnownHostsFile /dev/null prevents an unmanaged machine-wide file from silently expanding trust. UpdateHostKeys no stops automatic host-key updates from altering the pin set during agent work. ForwardAgent no prevents the remote machine from using a forwarded authentication agent to reach somewhere else.
The OpenSSH ssh_config manual explains that UpdateHostKeys supports host-key rotation when a host proves possession of a trusted key. That behavior can be useful for interactive fleets with disciplined host management. For an autonomous agent, automatic mutation makes incident review harder. A person should approve a production identity change and update the controlled host file deliberately.
Use a stable alias such as app-production in the agent request and reserve raw addresses for emergency procedures. The alias makes the approved target visible in logs and avoids commands that drift between names, temporary addresses, and copied shell snippets.
Test the effective configuration before granting the agent any credential path:
ssh -G app-production | grep -E '^(hostname|user|stricthostkeychecking|userknownhostsfile|forwardagent|updatehostkeys) '
Expect values resembling this:
hostname app-prod.internal
user agent_release
stricthostkeychecking yes
userknownhostsfile /Users/operator/.ssh/agent_known_hosts
forwardagent no
updatehostkeys no
This catches precedence errors from Include files, user defaults, and configuration management. I have seen careful host entries defeated by a later wildcard stanza that changed the user, enabled forwarding, or chose a different known-hosts file. The command that actually runs is the configuration you must inspect.
Account scope contains damage after a correct connection
A verified host can still receive a bad command, so give the agent an account that has a small, deliberate job. Do not hand an autonomous coding agent the same SSH login an operator uses for every emergency.
Account scope has four parts: which host the account reaches, which files and services it can affect, which privilege escalation paths it holds, and how long it remains usable. A separate account makes those answers inspectable. A shared deploy login turns every automation run into an attribution problem and usually ends with broad permissions because each new workflow needs one more exception.
A release agent on an application server might need to read a release directory, write a new artifact, invoke one deployment wrapper, and restart one service. It does not need a shell with unrestricted sudo, read access to every home directory, or the ability to alter SSH configuration.
Where the task is narrow, restrict the authorized SSH public key with a forced command. In authorized_keys, a server-side entry can bind that credential to a wrapper:
command="/usr/local/sbin/agent-release-wrapper",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleAgentPublicKey agent-release
The wrapper should parse a small argument set, reject shell metacharacters, log the requested operation, and invoke fixed binaries with fixed paths. Do not write a wrapper that accepts an arbitrary string in SSH_ORIGINAL_COMMAND and feeds it to sh -c. That merely moves unrestricted remote shell access behind a function name.
Forced commands do not fit every maintenance task. When an agent genuinely needs a shell for investigation, use a distinct investigation account with read permissions and no privilege escalation. Make a separate, explicitly reviewed route for changes. Mixing diagnosis and mutation in one broad account gives the agent too much room to turn an imperfect inference into an irreversible action.
Short-lived SSH certificates can reduce cleanup burden when you already operate a certificate authority with clear issuance controls. They do not remove the need for host verification. A certificate says something about the client account; the host fingerprint says which server received it.
Command review must show the effect, not a vague intent
A command approval is useful only when the person sees enough context to judge its effect. "Deploy release" is an intent. sudo systemctl restart payments-api on app-production as agent_release is an action a reviewer can evaluate.
Review the exact destination alias, remote account, literal command, arguments, and working directory. Also inspect whether the command invokes a shell, expands variables, reads a remote script, downloads content, or uses sudo. Those details decide whether a harmless-looking request can reach far beyond its description.
A reasonable approval record for a remote action looks like this:
Target: app-production (app-prod.internal)
Account: agent_release
Command: /usr/local/sbin/agent-release-wrapper activate 2025.04.17-rc2
Reason: activate the approved release after smoke tests
Compare that with this request:
ssh app-production "curl $URL | sudo sh"
The second command bundles remote content retrieval, shell execution, elevated privilege, and a value that may expand differently than the reviewer expects. No host-verification setting makes that safe. Reject it and require an artifact whose digest was checked earlier, a fixed deployment program, and arguments that name an approved release.
Command review has a failure mode of its own: approval fatigue. If an agent asks for confirmation on every harmless cat, git status, and service health check, people learn to approve without reading. Put read-only investigation behind a restricted account or a tightly defined command wrapper, then reserve interactive approvals for actions that write, restart, rotate, alter permissions, or cross a trust boundary.
Sallyport can keep SSH credentials inside its encrypted vault and require approval for each use of a selected credential, while its SSH channel executes through sp-ssh rather than exposing the credential to the agent. That still leaves you responsible for pinning the host and deciding whether the visible command deserves approval.
The dangerous failure path is usually a chain of ordinary shortcuts
Most SSH incidents around automation do not start with an exotic cryptographic break. They start with a shortcut that sounded harmless during setup.
Picture a release agent configured with StrictHostKeyChecking=accept-new, a shared deployment account, and an approval prompt that says only "run deploy." A DNS record points briefly at a replacement machine before the inventory update finishes. The agent sees an unfamiliar host, records its key, logs into the replacement because it accepts the shared account, and runs the deployment wrapper. The wrapper has broad write access because the shared account also supports emergency repair.
Nothing in that sequence requires an attacker. A routine naming error can deploy artifacts to the wrong environment, expose deployment output to an unintended machine, or modify an unprepared host. Add an attacker who can influence name resolution or routing, and the same shortcuts provide a much worse opening.
Each control interrupts a different point in that chain:
- A pre-pinned host record rejects the replacement machine until an operator verifies it.
- A dedicated account limits the harm if the approved target is still wrong in a way host identity cannot detect.
- An approval record containing the full command gives the reviewer a chance to notice an unexpected target or elevated action.
- A session and call trail lets the team reconstruct the request, approval, target, and result after a failure.
Do not replace the first control with the later ones. A reviewer may miss a target mismatch. An account restriction may have an overlooked privilege. A log explains damage after the fact. Pinning host identity prevents a class of connection errors before the remote account enters the picture.
Host-key rotation needs a change procedure, not an exception button
Host keys change for legitimate reasons: a machine is rebuilt, an image replacement occurs, an algorithm is retired, or an operator rotates credentials after suspected exposure. Treat those events as planned identity changes with evidence, not as warning dialogs to dismiss.
The person responsible for the host should obtain the replacement public host key from the new machine's console or another authenticated management path. They should publish the new entry to the controlled known-hosts file, record why it changed, and retire the old entry only after the cutover succeeds. If the machine must retain service during the transition, OpenSSH can store more than one accepted host key for the same hostname. That lets clients accept the old and new identities during a defined window.
Keep the rotation record small but specific: hostname, address if relevant, prior fingerprint, new fingerprint, reason, verifier, and expiry for any dual-key period. A ticket with "SSH changed" is not enough to distinguish planned work from someone teaching clients to trust an impostor.
Never instruct the agent to run ssh-keygen -R hostname and reconnect automatically. Removing the old record erases the mismatch signal before anyone has established why it occurred. An operator may use that command after verification as part of a reviewed update, but it should not be recovery logic inside an agent workflow.
Audit records should let you replay the decision
After an SSH action fails or surprises you, the useful record answers four questions: which agent process requested it, which identity approved it, what exact connection and command were used, and what result returned. "Agent deployed service" answers none of them.
Keep session records separate from individual-call records. A session identifies the agent run and lets an operator revoke its permission when it starts behaving badly. An individual-call record captures the host alias, account, time, approval outcome, command, and output or error. The distinction matters when one agent run makes a hundred safe reads and then one unsafe write.
Protect logs from the agent that generated the request. If an agent can edit the record after a remote command, the audit trail is a diary, not evidence. Append-only storage with integrity verification is a practical minimum. Also avoid putting secrets in commands or arguments, because a good audit log will preserve exactly what you ask it to preserve.
Sallyport projects session and activity journals from a write-blind encrypted, hash-chained audit log; sp audit verify can verify the chain offline without a vault key. Use that kind of evidence to investigate an exception, but design the host pinning, account restrictions, and approval text before an incident gives you a reason to read it.
The first operational change to make is simple: find every agent SSH configuration that can accept an unknown host, replace that behavior with a dedicated pinned host file, and test the effective settings with ssh -G. You will find stale aliases, accidental wildcard rules, and accounts that carry far more authority than their job requires. Those are the connections worth fixing before the agent gets faster.
FAQ
What is an SSH host fingerprint?
An SSH host fingerprint identifies the remote server's host public key. It lets the client detect whether it reached the server it expected or a different machine presenting a different host key. It does not identify the human or agent account that logs in.
Does an SSH key prove that the remote server is safe?
No. SSH host verification and user authentication solve different problems. A valid user credential can still be handed to an attacker if the client accepts the attacker's server as the intended host.
What StrictHostKeyChecking setting should an autonomous agent use?
StrictHostKeyChecking=yes is the safest practical default for an autonomous agent. It refuses unfamiliar hosts and changed host keys, so deployment stops until a person verifies the situation. That interruption is cheaper than quietly sending an account credential to the wrong endpoint.
Can I use ssh-keyscan to verify a production host?
No. ssh-keyscan asks the network endpoint for a host key but does not prove who controls that endpoint. Use it only when you compare its output with a fingerprint obtained over a separate trusted channel, such as a console or a signed infrastructure record.
What should I do when a server's SSH fingerprint changes?
Treat a changed fingerprint as an incident until the infrastructure owner confirms a planned host rebuild, host-key rotation, or replacement. Check the fingerprint through an independent path, update the pinned record deliberately, and preserve the old and new values in the change record.
Why should each coding agent have a separate SSH account?
A shared login hides which workload acted and usually accumulates broad privileges because every user needs something different. Give each agent workload its own account, restrict its allowed commands where feasible, and remove the account when the task ends.
What should I check before approving an agent's SSH command?
Review the literal remote command, its arguments, target host, account, working directory, and any shell expansion before approval. Commands that download and execute content, rewrite configuration, alter access control, or run through a shell deserve a higher bar than a read-only status check.
Does host verification prevent destructive SSH commands?
No. Host verification confirms the endpoint's cryptographic identity, not that a command is sensible or authorized. A verified production host can still receive a destructive command from an overprivileged account.
How do I handle new SSH hosts without trust on first use?
Use a signed host certificate or an independently managed source of fingerprints if your environment can support it. Do not turn a first connection by an agent into the approval event. An operator should establish the trust record before the agent can connect.
Can an action gateway replace SSH account restrictions?
Keep the account scoped to a repository, service, or narrow operational task, then record the session identity and each remote call. An action gateway can keep credentials away from the agent process, but it cannot make a poorly scoped remote account safe.