Verify host keys
Every SSH server proves it holds a host key during the key exchange. Checking that key against one you trust is what stops a
man-in-the-middle, so SshClientOptions.HostKeyVerifier is required and has no "accept anything" default. This page shows the
common policies and how to choose.
When to use which verifier
| Situation | Verifier |
|---|---|
Hosts are in the user's known_hosts (interactive tools, admin scripts) |
KnownHostsVerifier.ForCurrentUser(StrictHostKeyChecking.Yes) |
| A service with its own trust store, learning hosts on first contact | new KnownHostsVerifier([path]) { Policy = AcceptNew } |
| A few fixed servers whose fingerprints you know | SshHostKeyVerifier.Pinned(...) |
| Hosts with OpenSSH host certificates | @cert-authority lines in known_hosts, or a custom verifier |
| An inventory, CMDB or vault knows the keys | SshHostKeyVerifier.FromDelegate(...) |
Connecting by ssh_config alias |
nothing: the configuration's known_hosts and StrictHostKeyChecking apply |
known_hosts, strictly
// ~/.ssh/known_hosts and the system file; an unknown or changed key fails the connection.
var verifier = KnownHostsVerifier.ForCurrentUser(StrictHostKeyChecking.Yes);
A trust store of your own
// A known_hosts file the application owns, filled on first contact (trust on first use) and checked from then on.
var verifier = new KnownHostsVerifier(["/var/lib/myapp/known_hosts"])
{
Policy = StrictHostKeyChecking.AcceptNew,
HashNewEntries = true, // HashKnownHosts yes: host names are not readable in the file
UpdateHostKeys = true, // follow the server's key rotation (UpdateHostKeys yes)
};
| Policy | Known key | Unknown host | Changed key |
|---|---|---|---|
Yes |
accepted | rejected | rejected |
AcceptNew |
accepted | recorded and accepted, unless another key type is already known for the host | rejected |
Ask (default) |
accepted | AskHostKey decides (rejected without it); recorded when accepted |
rejected |
No |
accepted | accepted and recorded | accepted (not recorded) |
Caution
StrictHostKeyChecking.No accepts a changed key, which is exactly what an interception looks like. Use it only where
interception is not a concern, such as a throwaway container in a test.
Pin fingerprints
// The fingerprints as ssh-keygen -lf prints them; any one of them is accepted (for example during a key rotation).
var verifier = SshHostKeyVerifier.Pinned(
"SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU",
"SHA256:kKiu2Km+4JVJ4V3VzRUknvNqDe4cM1Kwu884+5BThkA");
Fingerprints are written as ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub prints them, with the SHA256: prefix.
Ask your own source of truth
// Your own source of truth: a CMDB, a vault, a database of provisioned machines.
var verifier = SshHostKeyVerifier.FromDelegate(async (context, ct) =>
await inventory.IsTrustedAsync(context.HostKeyAlias ?? context.Host ?? "", context.HostKey.Fingerprint, ct));
Write a verifier
Derive from SshHostKeyVerifier for full control. This one accepts only host certificates from one authority, issued for the host
being connected to:
/// <summary>Accepts only host certificates signed by one authority, with the host's name among their principals.</summary>
internal sealed class HostCertificateVerifier(string authorityFingerprint) : SshHostKeyVerifier
{
public override ValueTask<SshHostKeyVerdict> VerifyAsync(SshHostKeyContext context, CancellationToken cancellationToken)
{
var host = context.HostKeyAlias ?? context.Host;
var verdict = context.HostKey.Certificate switch
{
null => SshHostKeyVerdict.Reject("The server presented no host certificate."),
{ Type: not SshCertificateType.Host } => SshHostKeyVerdict.Reject("Not a host certificate."),
var c when c.AuthorityFingerprint != authorityFingerprint => SshHostKeyVerdict.Reject("Signed by an unknown authority."),
var c when DateTimeOffset.UtcNow < c.ValidAfter || DateTimeOffset.UtcNow >= c.ValidBefore => SshHostKeyVerdict.Reject("Expired or not yet valid."),
var c when host is null || !c.Principals.Contains(host) => SshHostKeyVerdict.Reject("Not issued for this host."),
_ => SshHostKeyVerdict.Accept(),
};
return ValueTask.FromResult(verdict);
}
}
With known_hosts, the same is a single line: @cert-authority *.example.com ssh-ed25519 AAAA....
Get a new server's keys
// ssh-keyscan: the server's host keys, without logging in. Compare them with fingerprints you got another way
// (the provisioning output, the cloud console) before trusting them.
var keys = await SshClient.ScanHostKeysAsync("new-host.example.com", cancellationToken: cancellationToken);
foreach (var key in keys)
{
Console.WriteLine($"{key.KeyType} {key.Fingerprint}");
}
Warning
A scan proves the server holds the keys, not that you reached the right server. Compare the fingerprints with ones obtained another way (provisioning output, a cloud console) before you trust them.
Handle a rejected key
try
{
await using var client = await SshClient.ConnectAsync("example.com", 22, options, cancellationToken);
}
catch (SshHostKeyException ex)
{
// Never retry with a looser policy here: a changed key is exactly what an interception looks like.
Console.Error.WriteLine($"Host key {ex.HostKey?.KeyType} {ex.HostKey?.Fingerprint} not trusted: {ex.Message}");
throw;
}
Notes and pitfalls
- Connections over a stream (a tunnel,
ProxyCommand) have no host name: setHostKeyAliassoknown_hostscan be consulted. - Revocation.
RevokedHostKeys(a key list or an OpenSSH KRL) refuses keys in every mode, even onesknown_hoststrusts. - Key rotation.
UpdateHostKeys = trueadds keys the server proves it holds after login and removes ones it no longer has, so a rotation does not break the next connection. - After connecting,
client.HostKeyis the verified key (KeyType,Fingerprint,PublicKey, andCertificatefor a host certificate).
See also
- Host keys (in-depth:
UpdateHostKeys, revocation lists, scanning options) - Security model
- KnownHostsVerifier and SshHostKeyVerifier in the API reference