← Writing

Building an OIDC provider on Cloudflare Workers so two products share one login

Why we built a first-party OIDC/SSO provider — PKCE, RS256, JWKS, single-use codes in Postgres — instead of buying one, and the code-consumption ordering bug review caught before launch.

On this page

An AI-agent platform I work on as fractional CTO absorbed a second product: same company, separate site, and — the requirement that matters here — one login. A user who signs up on either site should be signed in on both, with one identity, one wallet, one account page.

The textbook answer is “use an identity provider.” We built one instead — a first-party OIDC provider running on Cloudflare Workers, with the platform as the identity authority and the second product as a relying party. This post is the shape of that build, the honest build-vs-buy reasoning, and the one bug worth telling other people about.

Why build, at this scale

The buy options are real: hosted identity platforms do OIDC well and have absorbed decades of attack traffic. We still built, for reasons specific to the situation rather than universal:

  • The identity already existed. Users, sessions, and accounts lived in the platform’s Postgres. A hosted IdP would have meant either migrating identity out or wiring a sync — both bigger projects than serving OIDC from the system of record.
  • Exactly one relying party, first-party, under the same engineering roof. The threat model and integration surface are a fraction of what a general IdP handles.
  • The runtime was already there. The API runs on Workers; the OIDC endpoints are a handful of routes and some WebCrypto.

If either product had been third-party, or if we’d needed the long tail of enterprise features (SAML bridges, SCIM, admin-configurable connections), the answer flips to buy. Build-vs-buy for auth isn’t ideology; it’s counting relying parties and features.

The shape of the provider

The design is deliberately the most boring modern profile of OIDC:

  • Authorization-code flow with PKCE — no implicit flow, no password grant. The relying party generates a code verifier, sends the challenge, and must present the verifier at token exchange.
  • RS256-signed tokens with the signing keys held by the provider, and a JWKS endpoint publishing the public keys so the relying party verifies signatures without any shared secret.
  • Single-use authorization codes stored in Postgres — short-lived rows, consumed on exchange. The database gives us what stateless designs struggle with: an authoritative, race-resistant record of whether a code has already been used.

Nothing clever, and that’s the point. Every clever idea in a homegrown auth flow is a place a reviewer hasn’t seen a thousand times before.

The bug review caught: consume last, not first

The one implementation bug worth broadcasting: the first version consumed the authorization code before validating the redirect URI and PKCE verifier.

The token-exchange handler looked reasonable — fetch the code row, mark it used, then check that the redirect URI matched and the PKCE verifier hashed to the stored challenge. But that ordering means a request that fails validation still burns the code. At minimum that’s a correctness bug: a legitimate client that fumbles a parameter loses its code and strands the login. In nastier scenarios, anything able to lob invalid exchange requests at the endpoint can invalidate codes it never could have redeemed.

The fix is a rule you can carry to any implementation: validate everything that can be validated, then consume the code as the final atomic step. The consumption itself should be the race guard — a single conditional update that succeeds for exactly one caller — and it should happen only on an otherwise-fully-valid request.

Credit where due: this came out of code review, not production. It’s exactly the kind of ordering bug that passes every happy-path test.

What to check in any homegrown OIDC

If you’re reviewing one of these — yours or someone else’s — the checklist that matters is short:

  1. Exact redirect-URI matching. Registered URIs compared exactly, no prefix matching, no open redirect via a redirect_uri the provider “helpfully” accepts.
  2. PKCE enforced, not optional. The challenge stored with the code; the verifier required and checked at exchange.
  3. Single-use enforcement that’s actually atomic. One conditional write consumes the code; concurrent exchanges can’t both win.
  4. Ordering: validate, then consume. See above — the failed request must not burn the code.
  5. state round-tripped and checked by the relying party, so the callback can’t be satisfied by a response the client never requested.
  6. nonce bound into the ID token and verified, tying the token to the specific authentication request.
  7. Short code lifetimes and clean expiry — expired rows rejected and swept, not trusted to a TTL you never test.
  8. Keys you can rotate: JWKS serving current and next keys, so rotation is a rollover, not an outage.

Where it landed

One login now works across both products, the identity stayed in the system that always owned it, and the provider is a few hundred lines of well-reviewed Worker code rather than a new vendor relationship. I’d make the same call again under the same constraints — and a different call the moment a second external relying party shows up.

← All writing Book a call →
Book a call → WhatsApp