You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Split out of #272, where it blocks progress. That PR enforces the DPoP sender-constraint at GET /oauth2/token/verify (nginx auth_request / Caddy forward_auth / Traefik forwardAuth), and doing so exposes a design question about jti consumption that we should answer before writing more code. It is our decision to make, not the contributor's.
The problem
Verifier.Validate consumes the proof's jti as its last step (pkg/dpop/verifier.go:172):
In zeroid that store is Postgres-backed (server.go:333, postgres.NewDPoPReplayStore(db)). Validation and replay-consumption are currently a single, inseparable operation — there is no way to check a proof without burning it. The option surface (pkg/dpop/options.go) covers clock skew, max age, now, logger, URL normalizer and max jti length; nothing governs replay.
That is exactly right for the token endpoint, where zeroid is the party the proof is addressed to. It does not hold for a forward-auth gate, which sits in front of someone else's resource server.
Consequence 1 — the gate burns the jti the resource server needs
RFC 9449's model is that the resource server validates the proof for the request it is serving. If a forward-auth gate validates first and consumes the jti, the upstream's own validation then hits replay and fails.
So enabling DPoP enforcement at the gate can break DPoP enforcement at the RS — the opposite of the intent. It only works today if no upstream behind the gate independently validates, which is a coupling we would be silently depending on.
Consequence 2 — a database write on every proxied request
The verify endpoint gates all upstream traffic, not just token issuance. Consuming a jti there means one Postgres INSERT per authenticated request, on the hot path. That is a materially different performance profile from /oauth2/token, and worth deciding deliberately rather than inheriting.
Options
A. Add a non-consuming validation mode to pkg/dpop. A per-request flag or Option that runs every check — signature, typ, htm/htu, iat freshness, exp/nbf, ath, cnf.jkt binding — but skips store.Insert. Forward-auth uses it; the resource server stays the replay boundary.
Replay of a captured proof remains the RS's job, which is where RFC 9449 puts it.
No hot-path write, no double-consumption.
Cost: a small public API addition, and a deployment where the upstream does not validate gets possession-checking without replay protection. That should be documented rather than implied.
B. Treat forward-auth as the sole enforcement point. Keep consuming at the gate and document that upstreams behind it must not independently validate.
No API change.
Real replay protection at the gate.
Cost: keeps the per-request write, and makes correctness depend on upstream behaviour we do not control. One upstream doing the right thing by RFC 9449 breaks the deployment.
C. Make consumption idempotent for the same proof + same request. Key the store on something like (jti, htm, htu, ath) so gate and RS can both validate one proof.
I do not think this survives contact: "the same proof for the same request, twice" is indistinguishable from a replay without threading a request identity through the whole chain. Listed for completeness; I would not pursue it.
D. Do not enforce DPoP at forward-auth. Document that DPoP belongs at the resource server, and keep verify to introspection + revocation.
A, with the semantics written down explicitly: the gate performs a possession check, the resource server owns replay. That keeps the security property #272 is chasing, avoids the write amplification, and does not make our correctness contingent on upstream behaviour.
Worth deciding as a group though — B is defensible for a closed deployment where we control every upstream, and D is defensible if we would rather not ship a half-guarantee.
Blast radius
pkg/dpop is the public OSS primitive; Cerberus, Shield and Firehog consume it with their own storage (server.go:329-332). Any option added here is a public API addition on that surface, so worth getting the shape right the first time. Additive, so no consumer breaks.
Note that #272 has a second, independent problem — it compares the proof against the proxy's subrequest (GET /oauth2/token/verify) rather than the original upstream request, so it currently rejects every DPoP-bound token. That one is a straightforward fix and is tracked in the PR review; this issue is only about the jti decision.
Split out of #272, where it blocks progress. That PR enforces the DPoP sender-constraint at
GET /oauth2/token/verify(nginxauth_request/ Caddyforward_auth/ TraefikforwardAuth), and doing so exposes a design question aboutjticonsumption that we should answer before writing more code. It is our decision to make, not the contributor's.The problem
Verifier.Validateconsumes the proof'sjtias its last step (pkg/dpop/verifier.go:172):In zeroid that store is Postgres-backed (
server.go:333,postgres.NewDPoPReplayStore(db)). Validation and replay-consumption are currently a single, inseparable operation — there is no way to check a proof without burning it. The option surface (pkg/dpop/options.go) covers clock skew, max age, now, logger, URL normalizer and max jti length; nothing governs replay.That is exactly right for the token endpoint, where zeroid is the party the proof is addressed to. It does not hold for a forward-auth gate, which sits in front of someone else's resource server.
Consequence 1 — the gate burns the
jtithe resource server needsRFC 9449's model is that the resource server validates the proof for the request it is serving. If a forward-auth gate validates first and consumes the
jti, the upstream's own validation then hits replay and fails.So enabling DPoP enforcement at the gate can break DPoP enforcement at the RS — the opposite of the intent. It only works today if no upstream behind the gate independently validates, which is a coupling we would be silently depending on.
Consequence 2 — a database write on every proxied request
The verify endpoint gates all upstream traffic, not just token issuance. Consuming a
jtithere means one Postgres INSERT per authenticated request, on the hot path. That is a materially different performance profile from/oauth2/token, and worth deciding deliberately rather than inheriting.Options
A. Add a non-consuming validation mode to
pkg/dpop. A per-request flag orOptionthat runs every check — signature,typ,htm/htu,iatfreshness,exp/nbf,ath,cnf.jktbinding — but skipsstore.Insert. Forward-auth uses it; the resource server stays the replay boundary.Bearer.B. Treat forward-auth as the sole enforcement point. Keep consuming at the gate and document that upstreams behind it must not independently validate.
C. Make consumption idempotent for the same proof + same request. Key the store on something like
(jti, htm, htu, ath)so gate and RS can both validate one proof.D. Do not enforce DPoP at forward-auth. Document that DPoP belongs at the resource server, and keep verify to introspection + revocation.
Recommendation
A, with the semantics written down explicitly: the gate performs a possession check, the resource server owns replay. That keeps the security property #272 is chasing, avoids the write amplification, and does not make our correctness contingent on upstream behaviour.
Worth deciding as a group though — B is defensible for a closed deployment where we control every upstream, and D is defensible if we would rather not ship a half-guarantee.
Blast radius
pkg/dpopis the public OSS primitive; Cerberus, Shield and Firehog consume it with their own storage (server.go:329-332). Any option added here is a public API addition on that surface, so worth getting the shape right the first time. Additive, so no consumer breaks.Related
AgentAuthMiddleware. That path sees the real client request and is the RS itself, so it should keep consuming; no change expected there, but worth confirming when this is settled.Note that #272 has a second, independent problem — it compares the proof against the proxy's subrequest (
GET /oauth2/token/verify) rather than the original upstream request, so it currently rejects every DPoP-bound token. That one is a straightforward fix and is tracked in the PR review; this issue is only about thejtidecision.