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.jktand the response saystoken_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_jkton 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
Proofis ProofToken; the result'sThumbprintis JsonWebKeyThumbprint. Both classes are settable and unsealed, and the result records what each step read:JsonWebKey,Confirmation,Payload,AccessTokenHash,TokenId,IssuedAt. ValidateAccessTokenasks for the proof to be checked againstAccessToken: itsath, and the token'scnf(fromAccessTokenClaims) against the proof's key — "Missing 'cnf' value." / "Invalid 'cnf' value.". Until 0.138.0 anAccessTokenalone requiredath; a host validator that set it must now set this too.htuis 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:
jtiis checked beforehtmandhtu, 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,
cnfwas 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 oneDPoPheader 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
athcheck; it was set at zero sites insrc/, and its only non-null assignment anywhere was the unit test's own context builder.