Use case — DPoP nonce flow
What is DPoP, and what is the "nonce"?
DPoP ("Demonstrating Proof of Possession", RFC 9449) binds an access token to a key the client owns. On every API call the client attaches a DPoP: header carrying a fresh JWT signed with that key, proving "I'm still the same client that minted this token". A leaked DPoP-bound token is useless to an attacker who doesn't have the key.
The nonce is an optional hardening step from RFC 9449 §8 / §9. When a server requires one, it supplies a value that the client must include in subsequent proofs. The client may reuse an accepted nonce until the server supplies a new one, so the nonce is not a new value for every request and is not the replay detector by itself. Replay detection uses the proof's unique jti and server-side tracking; a nonce makes proofs prepared before the server's challenge harder to use.
Specs referenced on this page
- RFC 9449 — DPoP, §8 (server-provided nonce), §9 (resource-server-provided nonce)
- FAPI 2.0 Security Profile — nonce permitted
- FAPI 2.0 Message Signing — message-signing profile document; this library adds a nonce-source constraint when DPoP is active
Quick refresher
- DPoP proof — a small JWT the client signs per-request to prove it still holds the private key the access token is bound to. See Sender constraint for the basics.
- Pre-computed proof attack — an adversary that briefly accesses a client's machine could exfiltrate a stash of valid proofs and replay them later. The server's
jtireplay check remains the primary reuse defense; a required nonce also prevents proofs prepared before the server supplied an accepted value.
In short, the nonce flow blocks two classes of attack:
- Pre-computed proofs — when a nonce is required, proofs prepared before the server supplied an accepted value fail until the client obtains that value;
jtitracking handles reuse of an accepted proof. - Stage-and-fire — proofs prepared against an old nonce fail after the OP stops accepting that value.
Source:
examples/51-dpop-nonce
The flow
use_dpop_nonce as a normal error will retry forever; one that reads the header succeeds on the next attempt.The OP challenges a request that lacks an accepted nonce, or whose nonce is no longer accepted. After the client retries with the returned value, it can reuse that nonce until the OP supplies a new one; successful responses do not need to carry a DPoP-Nonce header.
nonce and jti serve different purposes. jti is a unique proof identifier, and the library's consumed-JTI store rejects a proof that is used again. A configured nonce source adds a server-accepted value to the proof and can rotate the current and previous values.
Wiring
The library ships an in-memory reference source. Single-process; not HA-safe, but fine for development and small-scale deployments:
import "github.com/libraz/go-oidc-provider/op"
src, err := op.NewInMemoryDPoPNonceSource(ctx, 5*time.Minute)
if err != nil { /* ... */ }
op.New(
/* required options */
op.WithFeature(feature.DPoP),
op.WithDPoPNonceSource(src),
)The rotation interval (5*time.Minute above) is how often the "current" nonce changes. The OP accepts both the current and previous values, so a client racing through a rotation boundary doesn't see a hard failure.
Multi-instance deployments
A process-local nonce source breaks across replicas — instance B has no record of the nonce instance A issued. Production HA deployments plug a shared store (Redis) behind a custom DPoPNonceSource. The library deliberately doesn't ship a Redis nonce source: the option matrix (TTL, rotation cadence, missed-rotation tolerance) is too deployment-specific to standardise.
When the OP demands the nonce
| Endpoint | Nonce required? | Set by |
|---|---|---|
/token | when a DPoP proof is presented and a DPoPNonceSource is configured | op.WithDPoPNonceSource |
/userinfo | when a DPoP proof is presented and a DPoPNonceSource is configured | same |
/par | when a DPoP proof is presented and a DPoPNonceSource is configured | same |
/device_authorization | when a DPoP proof is presented and a DPoPNonceSource is configured | same |
/bc-authorize (CIBA) | when a DPoP proof is presented and a DPoPNonceSource is configured | same |
/par and /token apply the same nonce challenge and validation, so an SPA that pushes authorization requests runs the same nonce-retry loop at /par that it already runs at /token.
Whenever a request presents a DPoP header, every endpoint in the table requires exactly one value. Multiple values are malformed and rejected, including an empty first value followed by a valid proof; the OP never selects a later value or silently treats the request as proof-less.
In this library, the FAPI 2.0 Message Signing profile requires a DPoP nonce source when DPoP is active, while profile.FAPI2Baseline leaves the source optional. The live FAPI 2.0 Security Profile permits nonce use; these profile-specific requirements are library constraints.
Verifying
# First call without nonce
curl -i -X POST -H "DPoP: <proof-without-nonce>" \
-d 'grant_type=authorization_code&code=...' \
http://localhost:8080/oidc/token | head -20
# HTTP/1.1 400 Bad Request
# DPoP-Nonce: <fresh-nonce>
# {"error":"use_dpop_nonce", ...}The DPoP-Nonce value goes into the next proof's nonce claim.
Read next
- Sender constraint — why DPoP exists at all.
- FAPI 2.0 Baseline — the library profile where a DPoP nonce source remains optional.