Skip to content

Decision: should DPoP validation consume the jti on a forward-auth gate? #274

Description

@saucam

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):

if err := v.store.Insert(ctx, proof.JTI, expiresAt); err != nil { ... }

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.

  • The gate still delivers what fix(handler): enforce DPoP sender-constraint on forward-auth verify #272 is actually after: proof of possession of the bound private key, which is what stops a stolen bound token being replayed as a bare Bearer.
  • 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.

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

Related

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions