Can SSH hostname canonicalization change the approved target?
Learn how SSH hostname canonicalization rewrites targets, re-runs client config, affects host-key checks, and changes what an approval must record.

An approval that says ssh build does not necessarily authorize the machine that receives the TCP connection. OpenSSH may expand that short name, follow a permitted CNAME, parse its configuration a second time, select a different user or port, and resolve the resulting name to one of several addresses. If an agent gateway approves only the text the agent supplied, its approval can describe one target while the SSH client uses another.
That is not an argument against canonicalization. Short names are useful, canonical names make configuration manageable, and host keys still authenticate servers. It is an argument for putting the approval boundary after OpenSSH has computed the effective destination and before it opens a socket. The person approving the action should see both the requested name and the final name and address, with any material config changes made visible.
One SSH command carries four different identities
An SSH target is not one string. Treating it as one string creates the bug this article is about. A serious approval path keeps four identities separate:
- The requested name is the argument supplied by the agent, such as
build. - The effective host name is the value OpenSSH uses after
Hostnamesubstitution and canonicalization, such asbuild.ops.example. - The peer address is the IP address selected when the client opens the socket.
- The authenticated identity is the host key or host certificate accepted for that connection.
These values answer different questions. The requested name records intent. The effective name controls later configuration matching and usually the host-key lookup. The address says where packets went. The host key says which server proved possession of a private key. An approval needs the first three before connection, while the audit result should add the fourth after key exchange.
OpenSSH itself exposes the naming distinction in ssh_config. The %n token means the original remote hostname from the command line, while %h means the remote hostname after configuration has done its work. %k is yet another value: HostKeyAlias if one is set, otherwise the original command-line name. Those tokens exist because OpenSSH cannot safely pretend that every stage has the same name.
The distinction also explains why a successful host-key check cannot repair a vague authorization decision. Authentication can prove that the endpoint owns an expected key. It cannot prove that the human meant to authorize that endpoint when the card showed only build. Authorization asks whether this action may reach this destination. Authentication asks whether the responding server matches an identity record. Both checks matter, and they happen at different moments.
Canonicalization can rewrite the name before connection
CanonicalizeHostname tells OpenSSH whether to perform explicit name rewriting. Its default is no, which leaves name lookup to the system resolver. With yes, OpenSSH tries the suffixes in CanonicalDomains for direct connections. With always, it also canonicalizes destinations reached through ProxyCommand or ProxyJump.
Suppose the agent requests this action:
host: build
user: deploy
command: /usr/local/bin/release status
The client may have this configuration:
Host *
CanonicalizeHostname yes
CanonicalDomains ops.example
CanonicalizeFallbackLocal no
CanonicalizeMaxDots 1
CanonicalizePermittedCNAMEs *.ops.example:*.hosts.example
OpenSSH first tries build.ops.example. If DNS returns a permitted CNAME to build-07.hosts.example, that target can become the canonical name. The request still says build, but the client no longer connects under that name. An approval card that displays only the request hides the transformation that matters.
The bounds deserve attention. CanonicalizeMaxDots defaults to 1, so canonicalization remains eligible for a name containing one dot, not only a bare label. CanonicalizeFallbackLocal defaults to yes; if the configured canonical domains do not produce a result, OpenSSH can pass the original name to the system resolver and its search rules. Setting it to no makes failure explicit. For an approval gateway, explicit failure is usually the better behavior because a local search list can vary by network and time.
CNAME following is constrained separately. CanonicalizePermittedCNAMEs defaults to none, and its rules pair allowed source patterns with allowed target patterns. That default is sensible. A broad rule such as *:* turns DNS aliasing into an unrestricted rewrite step. A narrow rule documents the namespace transition you actually expect, for example from service aliases under ops.example to hosts under hosts.example.
OpenSSH's manual is precise about these controls, but a policy layer must not merely copy the option values onto a card. It must run the same computation with the same client configuration. Reimplementing suffix searches and CNAME rules in an approval service tends to drift from the SSH client across releases and local config changes.
The second config pass can change more than DNS
When canonicalization is enabled, OpenSSH processes its configuration again with the new target name. That second pass can activate Host and Match blocks that did not match the original alias. The destination can therefore change in ways that a DNS-only precheck will miss.
Consider a canonical name that matches this block:
Match canonical host *.prod.ops.example
User release
Port 2222
IdentityFile ~/.ssh/prod_release
ProxyJump bastion.ops.example
A request for deploy might first expand to deploy.prod.ops.example, then pick up a different remote user, port, identity file, and jump host. OpenSSH uses the first value obtained for most directives, so whether those settings take effect depends on what earlier blocks already set and on their order. You cannot infer the final configuration by reading the matching block in isolation.
The canonical condition matches only during the parse after hostname canonicalization. The final condition requests a last parse even when canonicalization is disabled. When canonicalization is active, canonical and final match during the same pass. host matches the target after Hostname or canonicalization substitution, while originalhost matches the command-line value. These details make apparently simple configuration surprisingly stateful.
The dangerous review pattern is to approve host=deploy, user=agent, port=22, then hand ssh deploy to an ordinary client and assume those fields remain true. If the client config is still free to alter User, Port, ProxyJump, RemoteCommand, forwarding, or identity selection, the approval covered a proposal rather than the executed action.
A safer design freezes a material effective configuration after the final parse. At minimum, bind the approval to effective host, selected address, port, remote user, proxy route, remote command, forwarding requests, host-key alias, and the identity source that the client may use. If any bound value changes before connection, discard the approval and ask again. Do not silently preserve approval across a second evaluation.
Configuration provenance is part of the target
The final destination depends on which configuration files, command-line options, environment, and local network OpenSSH evaluated. Approving a target without recording those inputs leaves an easy path for the approved meaning to change. The OpenSSH manual gives its normal source order as command-line options, the user's configuration file, and the system configuration file. For most directives, the first value obtained wins.
An agent that can add arbitrary SSH flags can bypass a careful default. -F selects another configuration file, while -o can set Hostname, ProxyCommand, ProxyJump, Port, User, HostKeyAlias, or forwarding directives directly. A gateway should parse an SSH action into typed fields and reject unsupported options instead of accepting an opaque command string. If raw flags must be supported, classify every destination-affecting flag and include its normalized value in the approval.
Configuration files bring transitive inputs. Include accepts several paths, wildcards, tokens, and environment variables, and processes wildcard matches in lexical order. A file added to an included directory can therefore change which value wins without editing the top-level file. Record the content digest and ownership of every loaded file, not merely the path of the first file. Refuse files writable by the agent or by the working directory used for an untrusted repository.
Match exec is more than a conditional string comparison: OpenSSH runs the specified command under the user's shell and considers a zero exit status a match. Match localnetwork can vary configuration according to active interface addresses. The manual itself cautions that a locally observed network is not trustworthy for sensitive configuration when DHCP configures it. Those predicates mean the same ssh build request can produce another effective target after a VPN connects, a script changes its exit status, or an included file appears.
The approval executor should establish a closed input set before evaluation. Use a fixed client binary, a controlled configuration root, a scrubbed environment, known file ownership, an explicit address family, and a declared policy for user and system config. Compute a digest over that set and place the digest in the execution ticket. The digest does not need to dominate the card, but it must appear in the audit record and be checked again at use time.
Freezing config does not require banning useful per-host settings. It requires deciding who may author them. An operator-owned alias that maps build to a canonical production host can be safe when the approval displays the mapping. An alias created inside an agent-controlled checkout is part of the untrusted request, even if its syntax looks like ordinary OpenSSH configuration.
This provenance check also closes a common testing gap. Teams often test canonicalization with a clean temporary config, then execute with the developer's full config and system defaults. The test proves the temporary file, not the real action. Capture the exact evaluated inputs once and carry them through approval and connection.
Treat the OpenSSH version as an input too. Defaults and accepted directives change, and a fleet can contain several client builds even when every machine reads the same shared file. Record the executable path and version in the ticket, then rerun compatibility tests before an upgrade reaches agent traffic. A parser written against one release should reject unknown output instead of guessing that a renamed or newly added field is harmless.
Config provenance also decides whether an approval can be reused. Reuse is defensible only when the request, complete evaluated configuration, resolution result, and connector state remain identical within a short validity window. A matching nickname by itself proves none of that. Prefer a new compact approval over a long-lived grant whose effective destination can drift unnoticed between agent runs.
DNS answers can change after the card appears
Even when the effective host name stays fixed, its address can change between approval and connection. DNS rotation, split-horizon views, VPN changes, resolver search paths, and ordinary record updates can all produce a different answer. A classic time-of-check to time-of-use gap appears if the approval service resolves the name, shows an address, and the SSH process resolves it again later.
The fix is not to label DNS as untrusted and ignore the address. The fix is to bind execution to the exact resolution result that the approver saw. Resolve once inside the trusted executor, select an address according to the client's address-family rules, display it, and pass that selected socket address into the connection path without another name lookup. Keep the canonical name for Server Name Indication-like identity uses where applicable, host-key lookup, certificates, and logging, but do not let the transport silently choose a fresh address.
Multiple A or AAAA records need an explicit rule. Showing a set of addresses and then allowing the client to try any member can be reasonable, but the approval must say that it covers the displayed set and the audit log must record which member won. If the set changes, the old approval must not expand to include the new address. For high-risk hosts, approving one selected address is easier to reason about.
A proxy changes where local DNS resolution occurs. With ProxyJump, the client usually asks the jump connection to carry traffic to the ultimate host and port. A ProxyCommand can implement almost any transport behavior. The locally visible peer address may then belong to the proxy, while the final destination name is resolved elsewhere. The approval needs both hops: the concrete proxy endpoint that the local process reaches and the ultimate host value sent through it. Pretending the proxy address is the target loses the action's destination; pretending it is irrelevant loses the network path.
A valid host key does not validate the requested name
Strict host-key checking protects a different boundary from target approval. It should remain enabled, but it cannot tell whether a canonicalization result was within the human's intent. Shared host keys, host certificates with several principals, and an explicit HostKeyAlias can all make a cryptographically valid connection under a name other than the one shown for approval.
The SSH protocol architecture in RFC 4251 describes a local database that associates a host name as typed by the user with a host key. OpenSSH adds configuration-driven naming on top of that basic model. HostKeyAlias explicitly tells the client to use an alias instead of the real host name when it reads or writes host-key records and validates host certificates. This is useful for tunneling and several servers on one address, but it creates another name that an approval log should record.
RFC 4255, which defines SSHFP records, is unusually relevant here. It warns about unqualified host names and an injected DNS search path, and recommends checking a local host-key database before a DNS fingerprint in that situation. It also says an SSHFP record must not be trusted unless DNSSEC authenticated it. That advice is about authenticating a server, but it reinforces the broader point: name expansion changes the security context, and resolver output alone is not proof of identity.
RFC 4462 makes the warning sharper for GSS-API. It says implementations must not use insecure DNS results to construct the server target name because an attacker could change an alias or address mapping and impersonate the server. Even if your agent uses ordinary public-key authentication rather than GSS-API, the design lesson holds. An unprotected name rewrite should not quietly decide the identity that a security control claims to approve.
Host-key verification should bind the connection result back to the preflight record. Log the fingerprint actually presented, the name or alias used for lookup, whether a certificate principal matched, and the strict-checking result. If the client falls back to a prompt for a new key, that is a new security decision. An autonomous process should not convert it into an automatic acceptance merely because someone approved the shell command earlier.
Preflight must use the same OpenSSH inputs
A useful preflight reproduces the client's final configuration and then observes the connection path without executing the remote command. ssh -G prints configuration after evaluating Host and Match blocks. ssh -vvv adds resolution, connection, and host-key diagnostics, with the OpenSSH manual defining three -v flags as the maximum verbosity.
Run this sequence in a controlled environment, replacing build with the exact requested token:
ssh -G build | awk '
$1 == "hostname" || $1 == "user" || $1 == "port" ||
$1 == "proxyjump" || $1 == "proxycommand" ||
$1 == "hostkeyalias" || $1 == "canonicalizehostname" {
print
}
'
ssh -vvv -o BatchMode=yes -o SessionType=none \
-o ConnectTimeout=5 build 2>&1
The first command has an output shape like this:
user deploy
hostname build-07.hosts.example
port 22
canonicalizehostname true
proxyjump none
The verbose run then emits lines whose wording varies by OpenSSH release, but it identifies the expanded host, the selected address and port, the configuration passes, and the host key offered during key exchange. Capture structured facts from the executor rather than treating debug prose as a stable API. The commands are excellent for an operator investigating a setup; production code should obtain the same facts from the implementation that will open the socket.
There are two traps in this diagnostic. First, ssh -G shows evaluated configuration but does not prove which address a later connection will select and reach. Second, the verbose probe can open a network connection and perform key exchange even with SessionType=none; run it only when that probe itself is authorized. BatchMode=yes suppresses interactive password and host-key prompts, but it does not turn a connection attempt into a pure local calculation.
For a reproducible config investigation, isolate all inputs. Supply the intended user config with -F, account for the system config behavior of the client build, pin environment variables used by Include or token expansion, and record the OpenSSH version. Also record whether a control socket may reuse an existing multiplexed connection. A preflight against fresh DNS and config tells you little if execution attaches to an older master session.
Do not shell out once for approval and again for execution with independent parsing. The preferred architecture is one executor that loads config, canonicalizes, resolves, pauses with an immutable candidate record, and continues that same state machine after approval. If the SSH library cannot pause safely, create a signed or keyed execution ticket containing every material field and require the connector to reject any mismatch.
The approval record should show the transformation
A good card makes change visible without forcing the approver to read a debug log. Put the requested target next to the effective target and address. If nothing changed, say so compactly. If canonicalization or a Match block changed a material field, mark the old and new values.
A practical approval record can use this shape:
{
"requested": {
"host": "build",
"user": "deploy",
"command": "/usr/local/bin/release status"
},
"effective": {
"host": "build-07.hosts.example",
"address": "192.0.2.44",
"port": 22,
"user": "deploy",
"proxy": null,
"host_key_alias": null
},
"resolution": {
"canonicalized": true,
"source": "build.ops.example",
"permitted_cname": true
},
"binding": "sha256:REDACTED"
}
The binding should cover a deterministic encoding of the requested and effective fields, the remote command, forwarding options, relevant config identity, selected address or approved address set, and a short expiry. The connector recomputes the binding immediately before it opens the socket. If DNS, config, command, user, port, or proxy route differs, it stops.
Do not let the visual card omit a field merely because the binding includes it. Humans authorize what they can see. Show build -> build-07.hosts.example (192.0.2.44) as one readable transformation, then show user, port, proxy, and command. Reserve raw fingerprints and config provenance for an expandable detail view unless a new or changed host identity demands attention.
The awkward case is a hostname that resolves to a large or volatile pool. Do not approve an unbounded concept such as any current address for this name. Choose one connection candidate, approve a constrained published set with an expiry, or require a stronger stable identity such as a host certificate principal backed by a trusted CA. The chosen model should be stated, not inferred by the connector.
Proxies and connection reuse need separate bindings
Canonicalization behaves differently around proxies. CanonicalizeHostname yes applies only to connections without ProxyCommand or ProxyJump; always includes proxied connections. That one-word change can alter the ultimate name and activate the second configuration pass. An approval system must record the actual value rather than assume that canonicalization is globally on or off.
Each jump host is its own SSH connection with its own requested name, effective name, address, user, port, and authenticated host key. The ultimate target also retains its own identity through the tunnel. Approving only the final label misses a compromised or unexpected jump route. Approving only the jump server misses where it forwards the stream.
Connection multiplexing creates a less obvious bypass. If a matching ControlMaster session already exists, a later ssh invocation can reuse it instead of creating the connection that preflight predicted. The existing master may have resolved DNS earlier, used earlier configuration, and authenticated a host key before the current approval. Either disable reuse for gated agent actions or bind the approval to the master session's immutable connection identity and verify that identity before opening a channel.
The same rule applies to retries. When one address fails and the client moves to another, the retry remains authorized only if the second address was in the displayed approved set. A proxy fallback, alternate port, or newly canonicalized name is not a transport detail. It changes the object of authorization and needs a fresh decision.
Audit evidence must preserve both intent and outcome
A useful SSH audit entry lets an investigator reconstruct the entire transformation without rerunning DNS. Store the original agent request, effective OpenSSH configuration fields, canonical name, DNS chain, candidate addresses, selected address, proxy hops, accepted host fingerprint, certificate principal if present, and the command or subsystem requested. Timestamp the resolution and the socket connection separately so a timing gap is visible.
Sallyport routes SSH actions through its bundled sp-ssh helper while keys remain in the encrypted vault, so the agent never receives the SSH private key. Its Activity journal and Sessions journal come from one encrypted, hash-chained audit log, which gives the approval and execution path a place to retain both the requested and observed target rather than only a shell-shaped string.
Retention alone is not enough. Define invariants that a test can assert: the executed address belonged to the approved candidate set, the effective host and port matched the ticket, the accepted host identity was recorded, and every retry or proxy hop had authorization. Feed the test a config with CanonicalizeHostname, a permitted CNAME, a Match canonical block, and two DNS answers. Then change one fact between preflight and connect and verify that execution stops.
The failure worth preventing is mundane: an agent asks for build, a human recognizes the nickname and clicks approve, and a client on a different network expands it to a different domain. The SSH session may still be encrypted and the server may present a valid key for its own name. The approval is still wrong. Put the requested name, final name, and selected address in the same decision, and make the connector prove that it used exactly those values.
FAQ
What does SSH hostname canonicalization do?
It lets OpenSSH rewrite a requested host by applying configured domain suffixes and permitted CNAME rules. When enabled, it also triggers another configuration pass using the resulting name.
Is CanonicalizeHostname enabled by default?
No. OpenSSH documents CanonicalizeHostname no as the default, so explicit client-side rewriting is off unless configuration enables it. The system resolver may still apply its own search rules during ordinary lookup.
What is the difference between %n and %h in ssh_config?
%n is the original host token supplied on the command line. %h is the remote host after Hostname substitution and canonicalization, so approval and audit code should not treat them as interchangeable.
Can SSH canonicalization follow a CNAME?
Yes, but only when CanonicalizePermittedCNAMEs allows the source-to-target domain transition. Its default is none, and broad wildcard permission removes an important boundary.
Does ssh -G show the final IP address?
No. ssh -G is useful for the evaluated client configuration, including the effective hostname, but it does not establish which address a later connection will successfully use. Observe and bind resolution inside the executor that opens the socket.
Does a valid SSH host key make canonicalization safe?
A valid host key authenticates the responding server under the client's host-key rules. It does not prove that the approver intended to authorize the rewritten name or selected address.
Should an SSH approval display the hostname or the IP address?
Display both the requested and effective host names plus the selected address. The names explain intent and identity lookup, while the address records the actual network endpoint.
How should approval handle multiple DNS addresses?
Either approve one selected address or display a bounded set that the connector may try. Log the address actually used, and reject any address that was not part of the approved set.
Does ProxyJump change hostname canonicalization?
It can. CanonicalizeHostname yes skips proxied destinations, while always includes them, and every jump host introduces another connection identity that needs its own binding.
Can an existing SSH ControlMaster bypass target checks?
It can invalidate a fresh preflight if the new command reuses a connection created with older DNS or configuration. Disable multiplexing for gated actions or verify and bind the approval to the existing master's connection identity.