Table of Contents

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: set HostKeyAlias so known_hosts can be consulted.
  • Revocation. RevokedHostKeys (a key list or an OpenSSH KRL) refuses keys in every mode, even ones known_hosts trusts.
  • Key rotation. UpdateHostKeys = true adds 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.HostKey is the verified key (KeyType, Fingerprint, PublicKey, and Certificate for a host certificate).

See also