@o3co/auth-provider-dpop
v0.16.0
Published
DPoP (RFC 9449) sender-constrained access token support for @o3co/auth-provider
Readme
@o3co/auth-provider-dpop
Last updated: 2026-09-26
DPoP (RFC 9449) sender-constrained
tokens for auth.provider: a token issued against a DPoP
proof is bound to the client's key, and is refused from anyone who cannot sign
with it.
Responsibility
Role. The DPoP mechanism behind core's token-binding slot. Core defines
what a sender-constraint mechanism is — TokenBindingMechanism, in
core/src/middleware/tokenBinding.mts
— and the TokenBinding it produces
(core/src/grants/tokenBinding.mts).
Core composes one tokenBindingMw at /oauth/token from every mechanism
installed, and one protected-resource check from the same list. dpopModule
contributes the DPoP mechanism to both.
Owns:
- verifying a DPoP proof — structure, the
algallowlist, the signature over the embedded key,htm/htu, theiatwindow,jtireplay — and the proof'sathwhen it accompanies an access token.iat, and anexpornbfthe proof carries, must be a NumericDate (core'sisNumericDate: finite and within the Date range, a fraction allowed); JSON's1e400is amalformed_proof, not a clock outside the window; - the key thumbprint that becomes the token's
cnf.jkt; - server-provided nonces (
use_dpop_nonce,DPoP-Nonce); - what a proof's replay record is: its
jti, under a seen-set scope of its own per key (dpop-proof:<jkt>), kept forreplay-store-ttl-seconds; and the boot refusal of an enabled mechanism with no seen-set to record in; - the
dpop_signing_alg_values_supporteddiscovery field.
Does not own:
- which mechanism wins when mTLS is installed too, and the error that answers
a conflict — core's dispatch policy (
oauth.tokenBinding.dispatch-policy); - whether a grant stamps the binding on the tokens it mints, and which refresh
tokens are bound — the grants, on core's rules
(
oauth.tokenBinding.bindConfidentialClientRefreshTokens); - matching a presented proof against a refresh token's stored binding — core's
refresh-time matrix,
core/src/grants/confirmationMatch.mts; - where the replay records are kept. That is core's
ReplaySeenSetport, thereplaySeenSetslot thatprivate_key_jwtclient authentication and the WebAuthn challenge ceremony record in too; core'smemoryReplaySeenSetModuleand the replica-safety check that refuses it underdeployment.mode = "multi"; and@o3co/auth-provider-redis'sredisReplaySeenSetModule, the one replicas share. The composition root installs one of them.
Why a separate package. Sender-constraint mechanisms are plug-ins to one
core slot, not part of core: a deployment chooses DPoP by installing it, core
holds no DPoP vocabulary, and a new mechanism needs no core change. It is off
by default even when installed (oauth.dpop.enabled = false).
Status
Implemented: proof verification at the token endpoint, binding at protected
resources (ath), and server-provided nonces at both. Replay records are kept
in core's seen-set, so a deployment's replicas refuse each other's proofs
exactly when they share that set, and core's replica-safety check answers for
the memory one (see Operator requirements). Not
implemented: the dpop_jkt authorization-request parameter at /authorize
(RFC 9449 §10).
Install
npm install @o3co/auth-provider-dpop @o3co/auth-provider-corePeer dependency: @o3co/auth-provider-core. Optional peer dependency:
express@^5.0.0, whose types alone the package imports. The package depends
on jose and zod.
Quick start
import { createApp, memoryReplaySeenSetModule } from "@o3co/auth-provider-core";
import { dpopModule } from "@o3co/auth-provider-dpop";
const handle = await createApp({
modules: [
dpopModule,
// Where each accepted proof is recorded. One replica: the memory
// seen-set. Several: redisReplaySeenSetModule from
// @o3co/auth-provider-redis, which every replica shares.
memoryReplaySeenSetModule,
/* + your other modules */
],
bootstrapComponents: { config, /* ... */ },
});Enable DPoP in your application.conf:
oauth {
dpop {
enabled = true # default: false (secure-default opt-in)
iat-window-seconds = 60
alg-whitelist = ["ES256", "ES384", "EdDSA", "RS256"]
replay-store-ttl-seconds = 300 # at least 2 × iat-window-seconds + 1
}
# Cross-mechanism dispatch policy (owned by core):
tokenBinding {
dispatch-policy = "intent-explicit" # or "strict-mutual-exclusion"
}
}The defaults are the ones shown; the module's schema applies them, and the
package ships them as HOCON in src/reference.conf
(exported as @o3co/auth-provider-dpop/reference.conf). The public exports are
listed in src/index.mts. oauth.dpop.replay-store is
retired: the seen-set's own module chooses the backend, and a config that
still sets the key fails boot naming it.
Which tokens are bound. A public client's access token and refresh token
both carry cnf.jkt. A confidential client's access token is bound and its
refresh token is not by default — its client secret is the refresh-time
authenticator (RFC 9449 §5) — unless the deployment sets
oauth.tokenBinding.bindConfidentialClientRefreshTokens = true. At refresh,
core's matrix (confirmationMatch.mts)
requires the presented proof to match the refresh token's stored binding.
Cross-mechanism dispatch (DPoP + mTLS)
When both dpopModule and mtlsModule are installed, the oauth.tokenBinding.dispatch-policy config key (declared by core's config schema) decides what happens when both mechanisms succeed on the same request:
intent-explicit(default) — DPoP wins because the DPoP header is explicit-intent; mTLS cert is ambient.strict-mutual-exclusion— both succeeding is rejected with HTTP 400invalid_request.
See ADR 2026-05-20-token-binding-first-class-abstraction.md for the design rationale and packages/mtls/README.md for the symmetric view from the mTLS side.
A refused proof is a DPoPError with a reason (DPoPReasonCode) and this package's own fixed message; core's dispatcher logs it once, token_binding_proof_invalid / protected_resource_binding_proof_invalid, with the refusal's projection. The message never quotes what the client wrote — a typ that is not dpop+jwt is "typ is not dpop+jwt", an alg outside the allowlist "alg is not an accepted DPoP algorithm" — and dpop_alg_not_allowed (warn) names the refused alg only when it is a registered JWS algorithm, unregistered otherwise.
Discovery metadata
When oauth.dpop.enabled = true, this module contributes dpop_signing_alg_values_supported (RFC 9449 §5.1) to /.well-known/openid-configuration, carrying the configured alg-whitelist verbatim. It is the same read the proof verifier is constructed from, so an algorithm a client picks off discovery is one this deployment will accept.
Nothing is contributed while DPoP is disabled — a client then has no way to tell this module apart from an uninstalled one, which is accurate.
Server-provided nonces (RFC 9449 §8 / §9)
Without a nonce the only freshness control on a proof is iat skew, which is weak for tokens that live longer than a few minutes: a proof minted ahead of time stays usable for the whole window. oauth.dpop.nonce.required turns nonces on:
"as"— the token endpoint asks. A proof without a validnonceclaim gets400 use_dpop_noncewith aDPoP-Nonceheader, and the client retries with that value in the proof."as+rs"— protected resources (core'sprotectedResourceBindingMw) ask too:401withWWW-Authenticate: DPoP error="use_dpop_nonce"and theDPoP-Nonceheader."never"(the default) — no nonce.
The nonce is stateless: a time bucket and an HMAC under oauth.dpop.nonce.secret (OAUTH_DPOP_NONCE_SECRET, at least 32 bytes of key material measured on the decoded value as core's secret floor measures every operator secret — openssl rand -base64 32; shared by every replica). Nothing is stored and nothing is looked up on the proof path; a nonce minted by one replica verifies on every other, and the replay store is never consulted for a proof refused on its nonce — the client is about to present the same jti again with the nonce filled in. The bucket rotates every ttl-seconds (default 300) and the previous bucket stays accepted, so a client that received a nonce just before the boundary is not refused a moment later. Every accepted proof's answer carries the current nonce as well, so a client learns of a rotation before it needs to. A nonce is not single-use — replay of the proof is what jti and the replay store refuse; the nonce only bounds when the proof could have been made.
Boot refuses required without a secret: a per-replica random key would mint nonces no other replica could verify. There is no discovery-metadata flag for nonces — RFC 9449 signals the requirement at runtime with use_dpop_nonce, and a client that supports DPoP handles it there.
Replay store
Every accepted proof is recorded in core's ReplaySeenSet — the
replaySeenSet slot, the same seen-set private_key_jwt client assertions
and consumed WebAuthn challenges are recorded in
(core/src/replay-seen-set/types.mts).
The check is one markSeen, which records the value and answers whether this
call was the first to record it, as one atomic step: a check followed by a
separate write would let two concurrent requests both accept the same proof.
So of two requests carrying one proof, exactly one is accepted.
- Key. The proof's
jti, under the scopedpop-proof:<jkt>. The samejtiunder another key is a different proof, not a replay, and the scope cannot collide with another consumer's (client-assertion:<client_id>,webauthn:*, orjwt-bearer:id-jag:<issuer>where a composition hands the jwt-bearer verifier the same set). - How long a
jtimay be. At most 256 characters, and not empty (core'sMAX_JTI_LENGTH/isRecordableJti, the boundprivate_key_jwtand ID-JAG apply too). The proof is checked before the client is authenticated, so whoever sends it chooses the key the seen-set keeps forreplay-store-ttl-seconds; RFC 9449 §4.2 asks only that ajtibe unique, which a UUID (36 characters) or 96 random bits (16 in base64url) already is. A longer one isinvalid_dpop_proof(reasonmalformed_proof), refused inparseProofbefore the signature is checked and before the seen-set is consulted. - How long.
replay-store-ttl-secondsfrom the moment the proof is first accepted, which must be at least2 × iat-window-seconds + 1to outlive the proof's acceptance window; below that the mechanism logsdpop_replay_ttl_below_window(warn,iatWindowSeconds,replayTtlSeconds,requiredTtlSeconds; derivation:replayTtlSecondsinsrc/verifier.mts). A value that is not a positive finite number is refused when the mechanism is built. - When the store fails. A seen-set that cannot be reached refuses the
request as the server's fault, not the proof's:
503 temporarily_unavailableat the token endpoint and at a protected resource, with noWWW-Authenticatechallenge (reasonreplay_store_unavailable). A seen-set that answers with its own contract error (aRangeError, orexpired-at-issue) is broken rather than down: the same 503, with reasonreplay_store_fault, because the fix is in the composition, not in Redis. A seen-set that is full refuses the write the same way. Core's in-process set takes proofs only up to 90% of its cap (replaySeenSet.memory.maxEntries, a million records by default) and keeps the rest for its other consumers, soprivate_key_jwtand WebAuthn go on while DPoP is refused; that refusal is reasonreplay_store_full(ReplaySeenSetFullError,reason: "full"). A Redis atmaxmemoryundernoevictionisreplay_store_unavailable, and it refuses every consumer alike. Every proof is recorded before the token endpoint's rate limit and before a protected resource verifies the access token, so the rate that fills DPoP's share,0.9 × maxEntries / replay-store-ttl-seconds, is a rate anyone can send; a longer TTL lowers it in proportion. A Redis whose eviction policy deletes keys instead (allkeys-*,volatile-*) makes room by dropping replay records, and a dropped record is a proof that can be replayed within its window (the redis package's Requirements). Either way the mechanism hands the store's error upward as the refusal'scauseand logs nothing itself: core's dispatcher that answers the 503 writes the outage's one line at error level —token_binding_unavailableat the token endpoint,protected_resource_binding_unavailableat a protected resource — withmechanism: "dpop", thereason, and core'sloggableErrorprojection of the store's error, never the error: ioredis puts the refused write — the record's key — on it. (These replace the mechanism's owndpop_replay_store_unavailableanddpop_replay_store_faultlines, which logged the same outage a second time.) A proof the signature step refuses is loggeddpop_signature_invalidthe same way, because jose puts the proof's whole payload on a claim failure. A proof is never accepted unrecorded, and it is not called invalid either — RFC 9449 keepsinvalid_dpop_prooffor a proof that failed its checks (§5, §7.1), and a resource's401 invalid_tokenwould send the client to replace a token that is fine. The client retries later; the same answerprivate_key_jwtgives when its replay record cannot be written. - Ordering. A proof refused for its nonce or its
athis refused before the seen-set is consulted, so it does not spend itsjti.
The port has a conformance suite, and core's memory seen-set and the Redis one both run it (docs/adapter-surface.md).
Operator requirements
oauth.jwt.issuerMUST name the origin clients actually reach. Thehtua proof is checked against is built from the configured issuer's origin plus the path of the request, not fromreq.protocoland theHostheader (#292). Those two readX-Forwarded-Proto/X-Forwarded-Hostwhenever Expresstrust proxyis on, which would let a caller who could reach the AS past the edge choose the value its own proof had to match — satisfying both halves of the comparison at once. The issuer is a property of the deployment and no request can move it, which is the whole reason it is the right source. Boot fails with DPoP enabled and no issuer.The practical consequence: a deployment whose issuer is
https://auth.example.comverifies proofs whosehtunameshttps://auth.example.com/...regardless of what the proxy forwards, and regardless of whethertrust proxyis set at all. If clients reach the AS at some other origin, that origin — not the internal one — is the issuer you should have configured. A path prefix on the issuer is ignored: the path comes from the request, which already carries the prefix the AS is mounted under.http.trustProxystill matters for IP-keyed rate limiting and for the CSRF origin check — it is simply not load-bearing for DPoP.Replay protection across replicas needs a shared seen-set. A per-process seen-set is per process: with several replicas, a proof replayed to a replica that did not see it is accepted. Install
redisReplaySeenSetModulefrom@o3co/auth-provider-redis—replaySeenSet.adapter = "redis"in the standalone template — and every replica records in, and refuses from, the same set.With DPoP enabled and no seen-set wired, boot is refused in every
deployment.mode: the mechanism would have nowhere to record a proof, so it could refuse no replay. There is no per-process fallback. Installed withmemoryReplaySeenSetModule, the deployment gets core's replica-safety answer for that module, as for every other per-process store — DPoP adds no check of its own:|
deployment.mode| What boot does | | --- | --- | |"multi"| Refuses: areplica-unsafe-adapterBootErrornamingcore-replay-seen-set-memory, whose reason says a DPoP proof captured once can be replayed once against each replica. | | unset | Boots, and logs onereplica_unsafe_adapterswarning listingcore-replay-seen-set-memorywith every other per-process store. | |"single"| Silent: one replica, so the memory seen-set is correct. |The check reads the modules that are installed, so a per-process seen-set handed in as a bootstrap component (
createMemoryReplaySeenSet()) is not seen by it and boots under"multi"without a warning. DPoP left disabled records nothing and needs no seen-set.Installing a seen-set also turns on
private_key_jwt. The slot is shared, and a filledreplaySeenSetis also where aprivate_key_jwtclient assertion'sjtiis recorded — so filling it is what makes that method work. With the slot filled:@o3co/auth-provider-oauthadvertisesprivate_key_jwt, with its signing algorithms, intoken_endpoint_auth_methods_supportedand in the introspection list (and in the revocation list while revocation is on), and accepts it at/oauth/token,/oauth/introspectand/oauth/revoke(oauth/src/module.mts);@o3co/auth-provider-device-grantaccepts it atPOST /oauth/device_authorization(device-grant/src/module.mts);@o3co/auth-provider-federation-grantsaccepts it on every client route under/oauth/federation-grants:POST /:grantId/token,/:grantId/statusand/:grantId/revoke, and, with acquisition on,POST /and/:grantId/reauthorize(federation-grants/src/routes.mts).
Without a seen-set, a client registered with
jwks/jwksUriwas refused500 server_errorat every one of these. A composition that relied on DPoP's own store before v0.16.0, and now installs a seen-set for DPoP, turns all of them on. None is reachable without a client registered with keys, but the discovery document changes on its own, so check what it now offers.Upgrading from a release with
oauth.dpop.replay-store. DPoP's Redis records move fromdpop:replay:<jkt>:<jti>to the seen-set'sreplay:…dpop-proof:<jkt>…keys, and neither release reads the other's. While both serve against one Redis, a captured proof can be accepted once by an old replica and once by a new one. Each replica bounds a replay by its owniat-window-seconds(W): a proof the old release accepted can still be accepted by the new one for up to Wold + Wnew + 1 seconds after the last old replica stops — 121 s at the default 60 on both — plus the largest clock skew between replicas. To avoid it:- cut over stop-then-start, starting the new release at least Wold + Wnew + 1 seconds plus that skew after the last old replica stopped. No replica serves in the gap, so this is at least 121 s of downtime at the defaults, for every request, DPoP or not; or
- run a lowered W on both releases for the roll: restart the old release with it first, then deploy the new release with it. With W = 5 on both, a proof has 11 s in which it can be replayed across releases, and the window closes 11 s after the last old replica stops. Lowering it on the old release alone is not enough: a replay to a new replica is bounded by the new release's W, so at 5 and 60 it stays open for 66 s after the last old replica stops. Put the full W back on the new release no earlier than Wlow + Wfull + 1 seconds after the last old replica stopped (66 s at 5 and 60): sooner reopens the window for proofs the old release accepted. Clients whose clocks are off by more than the lowered W are refused while it is lowered.
The leftover
dpop:replay:*keys expire by themselves withinreplay-store-ttl-seconds(300 s by default); nothing reads them and nothing needs deleting. Deleteoauth.dpop.replay-storefrom the config before deploying: the new release refuses to boot while the key is set, and the old release reads its absence as its default"memory", under which a wireddpopReplayStoreis still the store it uses. Then deploy the new release with a seen-set module installed (redisReplaySeenSetModulefor several replicas) and without thedpopReplayStorecomponent, which is no longer read.
