This is the story of migrating QLM from a home-grown JWT login to OAuth 2.0 and OpenID Connect: what we had, why we changed it, and how the new handshake actually works. Part 2 covers where tokens live day to day. Part 3 covers what we locked down.
00 · Where we started
Before any of the protocol stuff made sense, we had a setup that worked but didn't scale. Worth naming plainly, because the migration only clicks once you see what we were replacing.
In the beginning, our app handled logging in the simplest way there is: it checked your password itself, then handed you a pass it had signed with its own key, and kept that pass sitting in your browser for up to a year.
One program did every job at once. It took your password. It decided the password was valid. It printed the badge. Later, at the API door, it checked that same badge. No second opinion. No dedicated login service. The app was vouching for itself.
It worked. But the badge lived in the browser far longer than it should. And every part of the system that needed to know "who is this?" had to trust the same party that issued the badge in the first place.
The client sent username and password straight to the API. The API verified them, signed a JWT with a shared secret, and sent it back. The browser stored it and attached it to every request for months.
We pulled password checking and token issuance into a separate role: an Authorization Server. The app you actually use (the translator client) never sees your password anymore. It only receives a short-lived pass after you've logged in elsewhere.
Not because OAuth is magic. Because it gives us a standard way to separate "who checked the password" from "who is asking for API access," with scoped tokens, registered clients, and a protocol we didn't have to invent from scratch.
This part of the series picks up right after that handoff. Once those passes exist, where do they live? How do tabs share them? What did we disable on purpose? That's Part 2 and Part 3. Here we focus on why we migrated and what the new flow looks like end to end.
01 · Why the industry built this
Our migration didn't happen in a vacuum. OAuth exists because a lot of teams hit the same wall we did, just fifteen years earlier.
Let its users import their Twitter contacts. The obvious way to build that, at the time, was the way everyone built it: ask the user for their Twitter password.
Engineers from Twitter and Ma.gnolia — soon joined by Google, which had been quietly wrestling with the same question — sat down and drafted something different: a way to grant access without ever handing over the password itself.
Nothing forced that. They could have shipped a one-off integration between two companies. Instead they wrote it down as something any two services could adopt — which is the only reason "OAuth" became a word instead of a Twitter implementation detail.
02 · The old pattern
Before tokens, the integration pattern looked a lot like our old home-grown login: hand over the real credential, hope the other side only does what it promised.
The mechanism: if you wanted App B to do something with your App A account — read your contacts, post on your behalf, import your photos — you typed your actual App A password into App B. That's it. That was the whole integration.
In checkpoint terms: it wasn't showing a boarding pass. It was handing a stranger your passport and trusting them to only look at the one page they said they needed.
03 · What we moved to
Three revisions of the same idea: stop sharing passwords, issue scoped passes instead, then standardize who those passes belong to.
The first formal answer to the problem above: tokens instead of shared passwords, scoped and revoked individually, without ever touching the underlying account credential. The cost: every request had to be cryptographically signed by hand, which made it genuinely painful to implement correctly.
A rewrite for simplicity: bearer tokens over TLS instead of signing every request, plus separate "grant types" shaped for different kinds of apps — a web server, a native mobile app, a machine talking to another machine. It became the de facto standard — but the spec stayed deliberately silent on one thing: it never standardizes who the user is, only what the token can do.
Every provider had filled that silence differently, so "log in with X" meant a different, incompatible integration for every X. OIDC layers a standardized identity assertion (the id_token, discovery, standardized claims) on top of OAuth 2.0's authorization mechanics — so delegated access and "who is this" finally speak the same protocol.
↳ our case at QLM
04 · Concept 101
Every OIDC flow has exactly three parties, and almost every security property comes from keeping their jobs separate.
Plain JWT auth (what we had before): the app itself checks your password and hands you a signed token. One party does everything. Collects the credential, verifies it, issues the pass, later checks the pass. Sound familiar? That's the setup in "Where we started" above.
OIDC splits that into roles on purpose: a dedicated Authorization Server is the only thing that ever sees your password. The Client (the translator app) never touches it. It only sees a code, then tokens, both handed to it after the fact. The Resource Server (the API) doesn't trust the client's word either. It independently checks the token before doing anything.
sub, iss, aud, nonce). This is the piece plain OAuth2 doesn't have — it's what makes it OIDC.code_verifier) before redirecting, sends only its hash (code_challenge), and must produce the original secret at token exchange. Stops a stolen authorization code from being redeemed by anyone but the client that started the flow.translator. The Resource Server checks this on every request; having a valid token isn't enough if it lacks the right scope.state ties the callback back to the request that started it (CSRF protection). nonce ties the id_token back to that same browser session (replay protection).05 · Before vs after
Same word, "token," three very different shapes depending on which era you're in. Click through. (How long they live and where they're stored is Part 2.)
One secret, everywhere. Whatever process holds JWT_KEY can both mint and verify — issuing and checking aren't separated.
Revocation isn't instant — it works by comparing tokenVersion on every request, a live check bolted on because the token itself can't be recalled once issued.
No standardized identity fields, no audience restriction, no PKCE-equivalent — because it was never issued through a redirect flow, there's no interception surface to defend in the first place, but also no separation between "requesting app" and "receiving app."
Asymmetric signing (RS256). The Authorization Server holds the private key; anyone — the client, the RS, a third app — can verify with the public key alone. Verifying no longer requires trusting whoever wrote the verification code with a shared secret.
aud means this identity assertion is only meaningful to the translator client — it can't be replayed against a different app that trusts the same AS.
This is the piece that makes it OIDC rather than bare OAuth2 — a standardized, verifiable "who is this," independent of whatever API you're about to call.
Opaque by design. A stolen token is just a random string to anyone who intercepts it — there's nothing to decode, and nothing works without a live lookup against the Authorization Server's own store.
Because the AS and RS are the same process here, that lookup is cheap — a Redis read. That's a deliberate, documented trade-off, not the only valid design: distributed systems often use signed JWT access tokens instead, trading instant revocation for not needing a network round-trip on every call.
Revoking an opaque token is instant — delete the record, and it's gone everywhere, immediately. A signed JWT that's already out in the world keeps working until it expires, no matter what you do server-side.
06 · Our migration in practice
This is the real path from the translator app to a usable API call, as we implemented it in edu-web + edu-translator.
It generates a random code_verifier, hashes it into a code_challenge, and picks a random state and nonce. Only the hash and the random values leave the browser at this point.
/oidc/authNot an API call from JS — an actual navigation. This matters: it means the AS gets to run its own login page in its own origin, and the client's JavaScript never sees the password field at all.
login.ejs posts to /oidc/interaction/:uid/login, which calls the shared verifyCredentials() — the same credential check the legacy password grant uses, so there's provably one place a password is ever checked, not two that could drift apart.
findAccount attaches identity claims — onceReads enrollment tables and sets organization_ids/enrollments as a static claim, but only when the translator scope was actually requested. This is the "static at login" decision — these values won't change again until the next login, on purpose.
/auth/callback with a code — not a tokenThe AS checks state matches what it was given at step 2, confirming this callback belongs to the flow that started it, then hands back a short-lived, single-use authorization code in the URL.
The client sends the code back to /oidc/token, along with the original code_verifier from step 1. The AS hashes it and checks it matches the code_challenge it was given. This is PKCE closing the loop — a code stolen off the wire is useless without the verifier that never left the client.
The id_token's nonce is checked against step 1's nonce, closing a second loop. The access token is stored server-side with its scope, enrollment claims, and token_version baked in at issuance.
TranslatorAuthGuard calls AccessToken.find() — a live Redis lookup, not a signature check — confirms the translator scope is present, and re-checks token_version and suspension status on every request, because those can change mid-session even though the token itself can't.
07 · Live demo
Same flow as above, as a swimlane you can click through. Real crypto in your browser: code_verifier, SHA-256, the whole exchange. Try the attack at the token step.
08 · Booth game
Six border crossings. Each one shows you what actually arrived at the checkpoint — not what it claims. Decide: let it through, or turn it away.
09 · What we left behind
We didn't jump to OIDC because a blog post told us to. We could see exactly which tier we were on, and which guarantees we were missing.
nonce/aud guarantees10 · Part 1 recap
Tap each one you can explain after reading Part 1. Token storage, refresh, and tabs come in Part 2. Attack surface and disabled features in Part 3.
Password is only ever seen by the Authorization Server — the client app never touches it
role separationA stolen authorization code is useless without the PKCE verifier that never left the client
PKCEstate proves the callback belongs to the request that started it — CSRF protection
nonce proves the id_token belongs to this browser session — replay protection
id_token is a standardized, signed identity claim — not a home-grown payload shape
id_tokenAccess tokens carry a scope the Resource Server checks — a valid token isn't automatically "allowed to do anything"
scopeRedirect URIs are allow-listed per registered client — the AS won't send a code anywhere else
registered clientLive checks (revocation, suspension) run per-request instead of being frozen into the token at login
live checksComing next
Part 1 was the migration and the handshake. The day-to-day questions came right after we shipped it.