Skip to content
All field notes

3 min read

Three Tokens Per Login

How we migrated a live auction marketplace from 30-day HS256 JWTs to short-lived ES384 tokens without logging out a single user

  • OAuth2
  • JWT
  • Authentication
  • PHP
  • Migrations

CattleUSA runs real-money cattle auctions every week. A buyer who gets silently logged out on Wednesday might miss a lot they drove three hours to preview on Monday. So when we decided to replace our aging auth system — a single 30-day HS256 JWT, minted by a framework-less PHP backend that predates most of the team's tenure — the hardest requirement wasn't technical. It was this: nobody gets logged out.

What we were replacing

The legacy scheme was one long-lived symmetric token. Every service that needed to verify a user shared the same secret, every token lived for 30 days, and revocation was a polite fiction. It worked in 2019. By 2025 we had sibling applications (a mobile app, an admin console, market-data products) that all needed to verify identity without holding a shared secret, and a 30-day bearer token is a 30-day liability window if a device is lost.

The target state: short-lived asymmetric tokens (ES384), verified by any service against a published JWKS, issued by a standards-compliant OAuth2 server with PKCE, refresh-token rotation, MFA, passkeys, magic links, and social login. We built that on league/oauth2-server — 82 files and about 15,000 lines under a namespaced v2/auth module, with 8 grant types and a 930-line WebAuthn passkey service. February was the crunch month: 76 commits.

The migration trick: issue all three

The problem with "old clients expect the old token, new clients want the new one" is that you don't control when clients update. Browsers cache the old bundle. The mobile app updates on the user's schedule, not yours. Sibling services parse claims you've renamed.

So the token endpoint issues three tokens on every login, simultaneously:

  1. The legacy 30-day HS256 token — byte-compatible with what the old backend minted, so every deployed client keeps working untouched.
  2. A short-lived HS256 token (15–30 minutes) — for the updated web UI, which was ready to refresh but whose verification path hadn't yet moved to asymmetric keys.
  3. A short-lived ES384 token — the end state, verified by sibling apps against the published JWKS.

The resource server validates with both algorithms during the crossover, so a request can arrive carrying any generation of token and still authenticate. And there's a detail I'm still fond of: the server inspects inbound bearer tokens, and if one arrives with more than 25 days of life left — meaning it was minted by the old system moments before the cutover — it persists that token into the new token store rather than orphaning it. Sessions created seconds before the deploy survived the deploy.

Compatibility is a claims problem too

Halfway through, we found that our Go services read the user ID from a legacy id claim while the OAuth2 library emits the standard sub. The fix is one line — copy sub into id at mint time — but the lesson is bigger: token compatibility isn't just algorithms and lifetimes, it's the exact claim shape every consumer has quietly memorized. We now treat the claim structure as a public API with a documented shape, because three different runtimes (PHP, Go, TypeScript) parse it independently.

What I'd tell someone doing this

  • Inventory consumers before you write code. Every service, every app version still in the wild, every cron job with a stashed token. The migration design falls out of that list.
  • Superposition beats cutover. Running old and new token schemes simultaneously feels wasteful. It's much cheaper than a support queue full of locked-out customers.
  • Make the end state boring. PKCE, JWKS, rotation — the value of standard OAuth2 is that the next engineer (or the next sibling app) integrates against documentation that already exists, not tribal knowledge.

The rewrite shipped into a live marketplace across February and March. Total user-visible auth interruptions: zero.