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 extra hardening step from RFC 9449 §8 / §9. Without it, a client can prepare DPoP proofs ahead of time and hold them; an attacker with brief access to the client could exfiltrate a stash and replay them later. The nonce closes that gap: the OP issues a fresh server-side nonce that must appear in the next DPoP proof. Pre-computed proofs immediately become invalid.

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. Without a nonce, those proofs stay valid for as long as their iat window allows.

In short, the nonce flow blocks two classes of attack:

  • Pre-computed proofs — an attacker that captured a proof can't replay it because they don't know the next nonce.
  • Stage-and-fire — long-lived proofs prepared offline expire when the OP rotates its nonce.

Source: examples/51-dpop-nonce

The flow

One rejection, then a nonce on every proof
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; every response after that carries the next nonce, so each proof is pinned to a value the OP chose.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-boundDPoP-Nonce: nonce-2 — use this next6GET /userinfo · DPoP: <proof, nonce-2>7200 · { user claims }DPoP-Nonce: nonce-3 — and so on
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 handshake happens once. After it, every later call carries the newest nonce: the OP returns a DPoP-Nonce header on each response, and the client copies that value into the nonce claim of the next proof.

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 issue and require the nonce symmetrically, 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.

FAPI 2.0 Message Signing forces the nonce on; FAPI 2.0 Baseline allows it. The library mirrors the spec — flipping the profile flips the default for you.

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.