Skip to content

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
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 jti replay 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; jti tracking 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 ​

One nonce challenge, then reuse of the accepted value
The OP refuses the first proof with use_dpop_nonce and hands back a nonce in a header. The client rebuilds the proof with that nonce and succeeds; later proofs may reuse the accepted value until the OP supplies a new one, and successful responses need not carry a DPoP-Nonce header.RP / clientholds priv_dpopOPgo-oidc-provider1POST /token · DPoP: <proof>no nonce yet — the client cannot know one2400 use_dpop_nonceDPoP-Nonce: nonce-13rebuild the proofwith nonce = nonce-14POST /token · DPoP: <proof, nonce-1>5200 · access_token, DPoP-boundnonce-1 remains accepted; no new header required6GET /userinfo · DPoP: <proof, nonce-1>7200 · { user claims }no DPoP-Nonce header on this success
The first rejection is not a failure to handle specially — it is the handshake. A client that treats 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:

go
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 ​

EndpointNonce required?Set by
/tokenwhen a DPoP proof is presented and a DPoPNonceSource is configuredop.WithDPoPNonceSource
/userinfowhen a DPoP proof is presented and a DPoPNonceSource is configuredsame
/parwhen a DPoP proof is presented and a DPoPNonceSource is configuredsame
/device_authorizationwhen a DPoP proof is presented and a DPoPNonceSource is configuredsame
/bc-authorize (CIBA)when a DPoP proof is presented and a DPoPNonceSource is configuredsame

/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 ​

sh
# 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.