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; it must be positive, and op.NewInMemoryDPoPNonceSource returns an error for zero or less. 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. Replicas share one source through Redis:
src, err := redisStore.DPoPNonceSource(time.Minute) // op/storeadapter/redis
if err != nil { /* ... */ }
op.New(
/* required options */
op.WithFeature(feature.DPoP),
op.WithDPoPNonceSource(src),
)The state is one key under the store's key prefix, so replicas that must agree share the prefix and a Redis instance. The source keeps the current and the previous nonce, as the in-memory one does, and the first call after the interval, on any replica, installs the next value. Replicas should keep their clocks well within the rotation interval of each other. If Redis is unreachable, IssueNonce returns an empty string and Validate returns false, so a Redis outage rejects nonce-bearing proofs; Failures() counts those calls. If the key is evicted, the next call mints a fresh value and clients holding the old one retry once. Keep the key on an instance whose eviction policy spares it.
When the OP demands the nonce
Every endpoint below demands a nonce when the request carries a DPoP proof and an op.WithDPoPNonceSource source is configured. What differs is the shape of the challenge:
| Endpoint | Challenge on a missing or stale nonce |
|---|---|
/token, /par, /device_authorization, /bc-authorize (CIBA) | 400 with JSON error use_dpop_nonce and a DPoP-Nonce response header |
/userinfo | 401 with WWW-Authenticate: DPoP error="use_dpop_nonce" and a DPoP-Nonce response header (RFC 9449 §9, resource-server form) |
/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. A client that keeps getting use_dpop_nonce is usually not reading the header, or is talking to a replica whose nonce ring differs from the one that issued the value.
Read next
- Sender constraint — why DPoP exists at all.
- FAPI 2.0 Baseline — the library profile where a DPoP nonce source remains optional.