Table of Contents

Signing keys

Every token this server issues — and, with SAML, every assertion — is signed with a key relying parties find in the key set. The code on this page is included from samples/Server.Tokens, which a test boots and asks for its key set.

Keys the server manages

By default this server manages its own signing keys: it generates one, persists it through ISigningKeyStore — files in KeyManagement.KeyPath, ./keys, since 0.137.0 — publishes it in the JWKS, and rotates it on the KeyManagementOptions schedule. A host that has set nothing gets that, and nothing on this page changes it. The schedule — rotation interval, propagation time, retention — and its defaults are under KeyManagementOptions.

// Automatic key management is on by default. These keep a current key for two algorithms — RS256 wrapped in a
// self-signed certificate (published as x5c) and ES256 — each rotated on its own schedule and both published.
// The first signs whatever names no algorithm of its own.
options.KeyManagement.SigningAlgorithms =
[
    new SigningAlgorithmOptions("RS256") { UseX509Certificate = true },
    new SigningAlgorithmOptions("ES256"),
];

// Where the default store keeps them: a directory every instance can write to, and shares.
options.KeyManagement.KeyPath = builder.Configuration["KeyPath"] ?? Path.Combine(builder.Environment.ContentRootPath, "keys");

A key per algorithm, certificates, and keys on disk — breaking in 0.137.0, Duende 8.0.1's, measured:

  • KeyManagement.SigningAlgorithms replaces SigningAlgorithm. Automatic key management keeps a current key for each — RS256/384/512, PS256/384/512, ES256/384/512, an EC key on its algorithm's curve — rotated on its own schedule and all published. A client's AllowedIdentityTokenSigningAlgorithms or a resource's AllowedAccessTokenSigningAlgorithms picks the key that signs; with neither, the first configured algorithm does. Empty is RS256 alone, as before.
  • UseX509Certificate (RSA only) wraps the key in a self-signed certificate — subject CN= your IssuerUri, or Fdyay.IdentityServer without one, valid for the key's retirement age — and the JWKS publishes it as x5c with its x5t. Every X509SecurityKey is published that way now, a certificate from AddSigningCredential too. A plain key stops signing for an algorithm that asks for a certificate, and stays published while tokens it signed live.
  • A duplicate algorithm, one no key can be generated for, or a certificate asked of an EC key fails at startup with Duende's message. Until 0.137.0 every algorithm but the RSA families was refused, because the generator was RSA alone; a key of an algorithm you stop configuring is no longer used or published.
  • The default store writes to disk: FileSystemKeyStore, one is-signing-key-{id}.json per key in KeyManagement.KeyPath — a keys directory under the working directory, as Duende's. A restart keeps the key and the tokens it signed; until 0.137.0 the default was in memory, and every restart invalidated them all. The directory must be writable, and shared if several instances are to converge on one key; a host with Dapper, or a store of its own registered before AddIdentityServer, is unaffected. Keys are protected with Data Protection unless DataProtectKeys is off, so the Data Protection key ring must be shared too.
  • IKeyManager.GetCurrentKeysAsync — one key per algorithm — replaces GetActiveKeyAsync, and the containers share a KeyContainer base, as Duende's do. A store keeps its keys: they are read as before and new ones are written in a form naming their kind, inside the protected data, so no table changes.

Ours, recorded: a static credential from AddSigningCredential still does not sign while automatic management is on — it is published for validation, for the migration below — where Duende signs with it first. Key files are this server's own format, not interchangeable with Duende's.

Several instances on one key store

New in 0.136.0, Duende 8.0.1's KeyManager, measured. Until 0.136.0 two instances starting together on a shared store (Dapper, or a store of your own) could each create the first key, read the store back before the other had written, and sign with their own for a day — each publishing a key set without the other's, so a token from one failed validation at the other. Now:

  • The oldest current key signs, so instances reading the same set choose the same key; a key dated a little ahead by another instance's clock is used rather than replaced.
  • While every key is within InitializationDuration (5 minutes), an instance that has just created one waits InitializationSynchronizationDelay (5 seconds) and reads the store again, and caches the set for InitializationKeyCacheDuration (1 minute) instead of KeyCacheDuration. InitializationDuration = TimeSpan.Zero turns the window off. The in-memory store, which no other instance can share, is never waited on — Duende has none.
  • DeleteRetiredKeys (on) decides whether a key past KeyRetirementAge leaves the store; retired keys are neither published nor used either way.
  • The options are checked as the key manager is built: a negative duration, a RotationInterval no longer than PropagationTime, and the like are refused with Duende's messages, and KeyCacheDuration is held to half of PropagationTime so a new key is seen well before it signs.

IKeyManager.GetCurrentKeysAsync(CancellationToken) (new in 0.162.0) is Duende's arity, as an overload beside GetCurrentKeysAsync(); KeyManager's passes the token to the signing-key store's loads, writes and deletes, to the initialisation wait and to its wait for another caller's rotation. It is a default interface method answering with GetCurrentKeysAsync(), so a host's own key manager keeps signing; the key material service asks this one with its caller's token, so a cancelled request no longer waits for a key. GetAllKeysAsync keeps its one arity (Duende's takes a token too).

Keys the host owns

A host that keeps its keys somewhere already — a vault, an HSM, a certificate the organisation rotates on its own schedule — turns automatic management off and supplies its own:

/// <summary>
/// Turns automatic key management off and signs with a certificate the organisation rotates itself. Both halves
/// are needed: with management off and no credential the server refuses to start.
/// </summary>
public static IIdentityServerBuilder AddOwnSigningKey(this IServiceCollection services, X509Certificate2 certificate)
    => services
        .AddIdentityServer(options => options.KeyManagement.Enabled = false)
        .AddSigningCredential(certificate);   // RS256 unless an algorithm is named

Both halves are needed. KeyManagement.Enabled = false on its own is a promise to supply a key, and breaking it fails at startup with a message naming the missing call — rather than falling back to a generated key, which would leave the server signing with something its operators do not know exists and cannot rotate. A certificate without its private key is refused at registration, not at the first token request.

AddSigningCredential on its own is the other half of a migration: the credential's public key joins the JWKS so tokens it signed keep validating, while the managed key goes on signing new ones until the flag says otherwise. AddValidationKey registers a public key alone, for the outgoing key in a manual rotation:

/// <summary>
/// A manual rotation: the new certificate signs, and the old one's public key stays published until the tokens it
/// signed have expired.
/// </summary>
public static IIdentityServerBuilder AddRotatedKeys(
    this IServiceCollection services, X509Certificate2 current, X509Certificate2 previous)
    => services
        .AddIdentityServer(options => options.KeyManagement.Enabled = false)
        .AddSigningCredential(current)
        .AddValidationKey(new X509SecurityKey(previous));

A key that never leaves its HSM or cloud key service, and so cannot be registered as a credential, signs through DefaultTokenCreationService.CreateJwtAsync instead (since 0.147.0) — a step of the token creation service a host overrides.

RSA or EC

As of 0.66.0 a credential on P-256, P-384 or P-521 is registered exactly as an RSA one is, and the rest follows on its own: the key is published with crv, x and y, discovery advertises the algorithm from the key rather than from a list, and tokens are signed with it.

/// <summary>
/// An elliptic-curve certificate registers as an RSA one does. The algorithm must be its curve's: ES256 for P-256,
/// ES384 for P-384, ES512 for P-521.
/// </summary>
public static IIdentityServerBuilder AddEllipticCurveKey(this IServiceCollection services, X509Certificate2 p256Certificate)
    => services
        .AddIdentityServer(options => options.KeyManagement.Enabled = false)
        .AddSigningCredential(p256Certificate, SecurityAlgorithms.EcdsaSha256);

The algorithm has to be the one its curve carries — RFC 7518 pairs ES256 with P-256, ES384 with P-384 and ES512 with P-521 — and any other pairing is refused at registration. A mismatch is not a weaker signature but an unusable key, and the JWKS would look perfectly well-formed while no relying party could verify anything with it.

What this looked like before is worth keeping, because the item was recorded as blocked for four releases and the blocker was ours. The note read: "the blocker is the published JWKS rather than the signing. Closing it means teaching PublicJsonWebKey the crv/x/y members an EC entry needs." Accurate, and it named one of the two places that emitted key parameters. The JWKS writer had its own fixed n and e, so the first attempt produced correct EC JWKs and published them with two null fields anyway — the exact outcome the original refusal existed to prevent.

Per-client token settings

// A relying party that verifies only ES256: its identity tokens are signed with the ES256 key.
new Client
{
    ClientId = "kiosk-app",
    ClientSecrets = { new Secret("kiosk-app-secret".Sha256()) },
    AllowedGrantTypes = { OidcConstants.GrantTypes.AuthorizationCode },
    RedirectUris = { "https://kiosk.example.com/signin-oidc" },
    AllowedScopes = { "openid", "profile" },
    AllowedIdentityTokenSigningAlgorithms = { "ES256" },
},

// A service whose resource servers have no use for a jti: its JWT access tokens carry none.
new Client
{
    ClientId = "batch",
    ClientSecrets = { new Secret("batch-secret".Sha256()) },
    AllowedGrantTypes = { OidcConstants.GrantTypes.ClientCredentials },
    AllowedScopes = { "orders" },
    IncludeJwtId = false,
},

New in 0.84.0, with Duende's names and rules. AllowedIdentityTokenSigningAlgorithms (empty: the server's default key) is the identity token's counterpart of an API resource's AllowedAccessTokenSigningAlgorithms: the client is the token's only audience, so it says what it can verify, and at_hash / urt_hash are sized for the algorithm chosen. With several keys registered — an RSA key for most relying parties and an ES384 one for a client that wants it, say — each client gets the one it named, and a client naming an algorithm no registered key has fails the issuance rather than receiving a token it cannot check. With automatic key management each configured algorithm has its own current key, so the setting selects among them — or refuses one that is not configured.

IncludeJwtId (true) set to false is for a client whose resource servers neither need nor want a jti; it gives up the one value they could use to recognise a replayed token, and RFC 9068 requires it on an at+jwt. It touches access tokens only — a logout token keeps the jti back-channel logout requires.