Players Engine

Devlog · Entry 12 · Engineering Notes

JWT at the edge

· Nic Vannetti · 5 min read

Authentication is a tax every request pays. The design question is where you collect it. Collect it in every service and you've bought yourself a dozen implementations of security-critical code to keep patched and consistent. Collect it once, at the edge, and everything behind the edge gets simpler — provided you're honest about what you've just started trusting. Players Engine collects at the edge. Here's the shape, and the two traps.

The pattern in one paragraph

Every client request enters through an API gateway. The gateway — not the services — validates the identity token's signature, expiry and issuer against the identity provider's published keys. If the token is good, the gateway forwards the request inward with a header carrying the verified identity claims; if not, the request dies at the door. Services read the forwarded identity and do authorization — what is this user allowed to do here? — but never again authentication. One place does crypto; everywhere else does business rules.

"Trusting a header" — safely

The reflexive objection: headers can be forged. Correct, which is why the pattern's real precondition isn't the gateway — it's the network posture behind it. The backend services are not reachable from the public internet; the only road in leads through the gateway, enforced by the platform's ingress controls and service-to-service IAM, not by hope. Inside that boundary, the identity header is as trustworthy as the gateway that wrote it. If your services are publicly reachable, this pattern is a vulnerability, not an architecture. Check the precondition before borrowing the design.

What it buys

No token-parsing dependency in any service. No JWKS fetching, caching or rotation logic replicated a dozen times, each copy one version behind the other. No possibility that service A accepts a token service B rejects. And one precise place to tighten when the threat model changes. On the cost side, the gateway's route configuration becomes security-critical — every route must declare its auth requirements explicitly, and the specs that define those routes are generated from source, never hand-edited, so a forgotten security annotation is a diff you can see rather than an absence you can't.

Trap one: the local-dev fallback

Local development has no gateway, so services carry a fallback: verify the token directly when the forwarded-identity header is absent. Useful, and dangerous — a fallback is an alternate door, and alternate doors have a way of staying unlocked. Ours is constrained to be dev-only, and the constraint is enforced by configuration validation at startup (the config discipline entry explains the machinery). The principle: a convenience path must be impossible to reach in production, not merely unused.

Trap two: the token outlives the truth

Stateless tokens have one property everyone knows and almost everyone eventually gets bitten by anyway: they stay valid until expiry no matter what happens to the account behind them. Delete an account, and for the token's remaining lifetime a perfectly signed, perfectly valid credential for a nonexistent user still walks through the front door. We hit exactly this during the beta — a freshly deleted test account could still write for a few more minutes. The fix wasn't shortening expiry (a blunt trade against UX); it was accepting that authentication answers "who signed this?", not "does this account still exist and stand in good standing?" — and routing every security-relevant decision through a chokepoint that consults current account state. The same re-check-the-source-of-truth reflex shows up in the async pipeline, where retried events must never trust their own stale payloads.

Test the door, not just the rooms

A gateway-centric design has a distinctive failure mode: a route whose security configuration is missing or wrong doesn't fail at the gateway — it forwards the request onward without identity, and the rejection finally happens somewhere deep in a service, wearing a confusing error message. The antidote is to test the boundary explicitly: for every route, an unauthenticated request must be rejected at the edge, and the shape of that rejection gets asserted, not assumed. It's the kind of test that feels redundant the day you write it and pays out the first time a route declaration gets fat-fingered during a refactor.

The summary card

Want in on a future wave?

Drop your email and we'll let you know when Players Engine opens up. Early supporters get first access.

← All devlog entries