Thoryn
  • oauth2
  • engineering
  • architecture
  • fapi

PAR (RFC 9126) and why state belongs in Redis from day one

PAR is the moment in OAuth where the client says "hold this for me" and the server returns an opaque handle. The catch — that handle has to resolve on a different pod than the one that minted it.

27 May 2026 · Mark Bakker

There is a moment in the OAuth flow where the client says "hold this for me" and the server returns an opaque handle. That moment is Pushed Authorization Requests — PAR, RFC 9126 — and the handle is a request_uri that looks like urn:ietf:params:oauth:request_uri:abc123. The long, tamperable authorization-request URL never enters the browser. Only the opaque handle does.

That handle has a less obvious property. It is created on one HTTP request, resolved on a completely different one, and in production those two requests will hit two different pods. If the first pod is the only place the handle lives, the second pod has nothing to look up. The redirect dies on the second hop with invalid_request_uri and the user sees an OAuth error page they cannot do anything about.

Two-for-one post: what PAR fixes, and why an in-process store would have been a launch incident waiting to happen.

What PAR fixes

In a vanilla authorization code flow, the client builds an authorization URL — client_id, redirect_uri, scope, state, code_challenge, sometimes claims — and the user agent navigates to it. The whole request lives in the browser's address bar; anyone who can intercept it can alter it.

PAR moves that conversation off the browser. The client POSTs the parameters to /oauth2/par over a server-to-server channel and the server returns { "request_uri": "urn:ietf:params:oauth:request_uri:abc123", "expires_in": 60 }. The client then redirects the browser to /oauth2/authorize?client_id=...&request_uri=.... That URL is short, opaque, tamper-resistant. Nothing about the user's intent ever sat in a URL the browser could rewrite.

The request_uri's lifecycle

Two endpoints. Two requests. Two pods.

First request: POST /oauth2/par. The server creates the handle and stores (handle → params). RFC 9126 says it must be opaque, single-use, short-lived; our TTL is 120 seconds.

Second request: GET /oauth2/authorize?request_uri=.... The server reads the handle, validates that the client_id on the redirect matches the one that minted it, looks up the parameters, continues. The handle is then deleted.

In between, the user agent has redirected and the load balancer has picked a backend. Behind any LB config that isn't sticky-on-client-IP, the second request usually doesn't land on the same pod as the first.

The multi-node trap

The temptation when you write the first version of the endpoint is the same as every cache: stash it in an in-process map and move on. It works on a laptop, it works in CI, it works in any test that runs against a single instance. Tests green, ship.

The failure mode shows up the first time you scale past one pod:

  1. The client POSTs /oauth2/par. The load balancer routes to pod A.
  2. Pod A generates the handle, stashes the parameters in its local map, returns the handle.
  3. The client redirects the browser to /oauth2/authorize?request_uri=....
  4. The browser follows the redirect. The load balancer routes to pod B.
  5. Pod B looks in its local map. Nothing is there. It returns invalid_request_uri.

Pod A still holds the handle, pod B is the one being asked. The OAuth error page has the right error code and the wrong root cause. From the operator's vantage point this looks intermittent — half the time the LB happens to route both requests to the same pod and the flow works fine. To the customer, the broker is broken on every other login.

Same shape as every other piece of cross-request state: split-brain, every pod locally consistent, the cluster inconsistent.

What we shipped

The Redis-backed PAR store is the only one wired into production. The write is a single Redis SET with a 120-second TTL; consume is get + delete. The key is par:<uuid>; the payload is a JSON blob with the client_id, the parameters, and a created-at timestamp. On consume, we check the client_id on the second request matches the one the handle was minted for — stops one client picking up another's request_uri if it leaks.

An in-memory PAR store exists for unit tests only. Same interface, no shared state across processes, deployed nowhere.

A request_uri minted on pod A is not visible to pod B without a shared store: same handle, different memory spaces, no overlap except via Redis

The relevant rule from our coding conventions:

Any state that must be consistent across requests — rate limits, nonces, session tokens, idempotency keys — must be stored in Redis, not in an in-process data structure.

PAR is on that list. The point of writing it down is so the next reviewer doesn't have to rediscover the rule from first principles on the next change.

The cache classification rule

Not every in-process cache is wrong. The hub keeps a per-pod token cache; if pod A and pod B each independently mint a valid token, both are valid. Worst case: a slightly higher signing-key call rate — no correctness invariant violated. Per-pod there is an optimisation, not a correctness mechanism.

The classification we apply at review time:

  • MUST be in Redis — correctness-sensitive across the cluster: rate limits, nonces, PAR handles, session tokens, JWKS rotation state, idempotency keys, brute-force counters.
  • Per-pod is fine — each pod independently produces a correct result; the cache is a latency or cost optimisation.
  • Remove — neither of the above; usually a single-node-prototype leftover.

PAR is the textbook MUST-be-in-Redis case. Exactly one valid request_uri; either it is found and consumed once, or it is not. Two pods with different views of "is this handle live?" is the failure.

The audit

The wider rule got its own follow-up: every in-process cache in the codebase classified, follow-ups filed for the ones hiding a MUST-be-in-Redis behind a works-on-my-laptop façade. The pre-prod cleanup post covers the larger story: nine items found before the customer plane went near production, one of which was the in-memory family of stubs that PAR's store used to belong to.

The thing PAR taught us: you do not get a second chance to discover this kind of bug in a quiet room. By the time it shows up, it has already shown up to a customer. Hence the rule. Production wiring uses Redis on day one. The in-memory implementation keeps tests fast. Every cache that holds cross-request state gets classified at review time. PAR is small, but it is the shape of every other piece of state the broker holds.