Table of Contents

DPoP

mTLS binds a token to the certificate the client authenticated with. A browser application has no certificate, so before 0.17.0 every token an SPA held was a bearer token — worth exactly as much to whoever copied it as to the client it was issued to. DPoP (RFC 9449) is the answer for those clients: the client holds a key pair, signs a proof per request, and the server binds the token to that key.

APIs shows the basics — RequireDPoP, a client's validation mode and the server-wide DPoPOptions — and the recorded flow shows a nonce-held client's requests and responses at the token endpoint and the local API. This page is the rest. Its code is included from samples/Protocols.Dpop, which a test drives through the nonce exchange, a replayed proof, and the local API.

What a proof buys

Nothing needs registering: the proof validator and the replay cache are always there, and cost nothing until a client sends a proof. RequireDPoP is off by default because turning it on for a client that is not sending proofs stops it working. Once it is on, in order of what an attacker would try:

  • The access token carries cnf.jkt and the response says token_type: DPoP, so a resource server can tell it must demand a proof.
  • The refresh token is bound too. Presenting it without a proof, or with a different key, is refused. Without this the refresh token would be the one bearer credential left in the flow — and the long-lived one.
  • dpop_jkt on the authorize request binds the code, so a stolen code cannot be redeemed under someone else's key.

Both bindings above were not in force before 0.77.1. The token endpoint checked them correctly, but both grant stores serialized through a DTO with no member for the key's thumbprint, so a bound code or refresh token came back from the store bound to nothing — redeemable with any key, or with no proof at all. Fixed in 0.77.1, with a test that round-trips every member of both grants through their stores by reflection, so the next member a DTO drops fails a test the day it is added. Refresh tokens issued under DPoP before 0.77.1 read back unbound and stay that way; revoke them if that matters.

The proof's validity window, the client clock skew this server tolerates and the signing algorithms it accepts are DPoPOptions. Discovery advertises the algorithms verbatim as dpop_signing_alg_values_supported, so a client is never told about an algorithm the validator would then refuse.

Holding a proof to a clock, per client — and server-provided nonces

New in 0.85.0, with Duende's names, values and defaults. A proof's iat is the client's clock; a client that cannot be trusted to keep it — or whose proofs could have been made ahead of time on a device someone held briefly — is held to this server's instead, with a nonce (RFC 9449 §8):

// A client held to this server's clock as well as its own: every proof must carry a nonce this server
// issued, and its iat must be within 30 seconds of now. The first request is answered use_dpop_nonce.
new Client
{
    ClientId = "field-app",
    ClientSecrets = { new Secret("field-app-secret".Sha256()) },
    AllowedGrantTypes = { OidcConstants.GrantTypes.ClientCredentials },
    AllowedScopes = { "field-data", IdentityServerConstants.LocalApi.ScopeName },

    RequireDPoP = true,
    DPoPValidationMode = DPoPTokenExpirationValidationMode.IatAndNonce,
    DPoPClockSkew = TimeSpan.FromSeconds(30),   // null takes the server-wide DPoP.ClientClockSkew
},
DPoPValidationMode The proof is held to
Iat (the default) its iat, within the client's clock skew — DPoPClockSkew, or DPoP.ClientClockSkew when that is null
Nonce a nonce this server issued, within DPoP.ProofTokenValidityDuration plus DPoP.ServerClockSkew (zero by default)
IatAndNonce both
Custom neither, from the shipped validator — for a host that replaced IDPoPProofValidator with its own

A nonce-mode client's first token request is answered 400 use_dpop_nonce with a DPoP-Nonce header; it signs that value into the nonce claim of its next proof and sends the request again. A nonce that is missing, was not issued here, or has expired is answered the same way, with a fresh one. Nonces are a Data Protection-wrapped timestamp under Duende's purpose string, so nothing is stored and every instance sharing the key ring accepts the others' — set ServerClockSkew if their clocks drift.

The proof is checked before the grant, since 0.85.0. A code is spent the moment it is validated, so in the other order the first, nonce-less leg of a code exchange would spend the code the retry needed — and any refused proof spent a grant it never reached.

The same rules where the token is used, since 0.86.0. At this server's own resource endpoints — /connect/userinfo and the local API — a proof is held to the mode and skew of the client the token was issued to. A nonce-mode client's call is answered 401 with WWW-Authenticate: DPoP error="use_dpop_nonce" and a DPoP-Nonce header (RFC 9449 §9, the resource server's nonce), and its next proof carries that nonce. Duende applies the client's rules at its local API handler; its userinfo endpoint reads no proof at all, while this one has checked bound tokens there since 0.45.0.

At the pushed authorization endpoint, since 0.87.0. A DPoP header on a push is validated for POST to /connect/par under the same client rules — a nonce-mode client's first push is answered 400 use_dpop_nonce with a DPoP-Nonce header — and binds the request to the proof's key: a dpop_jkt the request names must match it (invalid_request otherwise), and one it does not name is supplied from it, so the code the push becomes can be redeemed only with that key. Two DPoP headers are refused with invalid_request, and a proof longer than InputLengthRestrictions.DPoPProofToken (4000) with invalid_dpop_proof, here and at the token endpoint — all as Duende's pushed authorization endpoint and token request validator answer them.

Replacing one step of the proof validator

Breaking in 0.138.0, Duende 8.0.1's, measured. DefaultDPoPProofValidator is not sealed: its steps are protected and virtual, in Duende's order — ValidateHeaderAsync, ValidateSignatureAsync, ValidatePayloadAsync (then ValidateFreshnessAsync → ValidateIatAsync / ValidateNonceAsync, and ValidateReplayAsync), with CreateNonce, GetUnixTimeFromNonceAsync and IsExpired — so a host with one rule of its own overrides that step and keeps the rest. Here, the replay step, against a cache the host chose:

/// <summary>
/// The shipped proof validator with one step of the host's own: proofs already seen are remembered in a cache the
/// host chose, keyed by the proof's key as well as its <c>jti</c>. Every other step — header, signature, payload,
/// freshness, nonce — is the default's, in the default's order.
/// </summary>
public sealed class SharedReplayValidator(
    IOptions<IdentityServerOptions> options,
    IReplayCache replayCache,
    IEventService events,
    TimeProvider time,
    IDataProtectionProvider dataProtection,
    IDistributedCache proofs)
    : DefaultDPoPProofValidator(options, replayCache, events, time, dataProtection)
{
    private readonly IEventService _events = events;

    /// <summary>Refuses a proof presented before, and remembers this one for as long as a copy could be accepted.</summary>
    protected override async Task ValidateReplayAsync(
        DPoPProofValidationContext context, DPoPProofValidationResult result, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(result);

        var key = $"dpop-proof:{result.JsonWebKeyThumbprint}:{result.TokenId}";
        if (await proofs.GetAsync(key, cancellationToken) is not null)
        {
            // The default raises this event for a replay; an override that wants it raises it too.
            await _events.RaiseAsync(new DPoPProofReplayDetectedEvent(), cancellationToken);
            result.IsError = true;
            return;
        }

        var window = Options.DPoP.ProofTokenValidityDuration + (2 * Options.DPoP.ClientClockSkew);
        await proofs.SetAsync(
            key,
            [1],
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = window },
            cancellationToken);
    }
}
// Replaces the shipped validator everywhere a proof is checked: the token, PAR and userinfo endpoints and the local
// API. The in-memory distributed cache stands in for one every instance shares, such as Redis.
builder.Services.AddDistributedMemoryCache();
builder.Services.AddTransient<IDPoPProofValidator, SharedReplayValidator>();

An override replaces the default's step whole: the default raises DPoPProofReplayDetectedEvent for a replay, so an override that wants the event raises it, as this one does. What else changed in 0.138.0:

  • Renamed: the context's Proof is ProofToken; the result's Thumbprint is JsonWebKeyThumbprint. Both classes are settable and unsealed, and the result records what each step read: JsonWebKey, Confirmation, Payload, AccessTokenHash, TokenId, IssuedAt.
  • ValidateAccessToken asks for the proof to be checked against AccessToken: its ath, and the token's cnf (from AccessTokenClaims) against the proof's key — "Missing 'cnf' value." / "Invalid 'cnf' value.". Until 0.138.0 an AccessToken alone required ath; a host validator that set it must now set this too.
  • htu is compared by scheme and host without regard to case, port and path, as RFC 9449 §4.3 and Duende have it; a proof made for the URL with its query, or with the host in capitals, is no longer refused.
  • Order: jti is checked before htm and htu, as Duende's. A proof is remembered for its validity plus twice the widest clock skew it was held to, and times are compared in whole seconds.

Ours, recorded: the signature is also held to DPoP.SupportedSigningAlgorithms, a replay raises DPoPProofReplayDetectedEvent, and only a nonce that is not ours is treated as absent.

Presenting a bound token back to this server

Validating proofs at your resource server is that server's job. This server hosts resource endpoints of its own, and at those it is the resource server — so since 0.45.0 it checks the binding on every access token it is handed:

GET /connect/userinfo
Authorization: DPoP eyJhbGciOi...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...

The token arrives under the DPoP scheme, not Bearer (RFC 9449 §7.1), and the proof has to be for this method and URL, signed by the key cnf.jkt names, and carry an ath naming this token. A certificate-bound token is checked the same way against the certificate on the connection (RFC 8705 §3).

A refusal challenges with the scheme the token should have come under, because that is the only place a client is told what it is holding, and with the code Duende's local API answers (since 0.88.0):

Refusal Code
the proof is missing, doubled, or fails — a key other than cnf.jkt among the ways invalid_dpop_proof
a nonce is wanted use_dpop_nonce
the token itself, a DPoP-bound one sent as Bearer included invalid_token
an unbound or certificate-bound token presented under DPoP (since 0.89.0: the scheme belongs to DPoP-bound tokens only, RFC 9449 §7.1) invalid_dpop_proof, as Duende's validator answers a token without a usable cnf.jkt

Since 0.90.0 the challenge says why, in the shape Duende's local API writes — DPoP error="invalid_dpop_proof", error_description="Missing 'cnf' value." — with Duende's wording for the refusals made around the proof and the proof validator's own for the ones it makes; since 0.91.0 those are Duende's words too ("Missing 'nonce' value.", "Invalid 'ath' value.", …), at the token endpoint's error_description as in the challenge. A RequireDPoP client that sends no proof at all is invalid_request since 0.92.0 — "Client requires DPoP and a DPoP header value was not provided." — as Duende answers it: there is no proof to be invalid.

New in 0.45.0, and it is a security fix. Before it, cnf was written onto tokens by two mechanisms, relayed to third-party resource servers through introspection — and read back by nothing. A stolen DPoP- or certificate-bound token was an ordinary bearer token at /connect/userinfo, and token exchange would trade one for a fresh unbound token for another audience, which is worse than reading a resource with it: the constraint is gone rather than merely unchecked. A sender-constrained subject token is now refused at token exchange outright — a request carries one DPoP header and this one's is already spent binding the new token, so there is no proof left to check the subject token against.

The machinery had been present and unreachable throughout. AccessToken exists only for this call and drives the ath check; it was set at zero sites in src/, and its only non-null assignment anywhere was the unit test's own context builder.