OIDC series Part 1 · The migration Part 2 · Tokens & tabs Part 3 · Security knobs
GUIDE #002 · PART 1 OF 3

We had one app doing every login job. Then we split them apart.

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

One program, every job

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.

1
Before

Password in, JWT out

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.

2
The split

A dedicated login service

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.

3
After

OAuth 2.0 + OIDC

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

Where OAuth actually came from

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.

1
Ma.gnolia

Wanted one small thing from Twitter

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.

2
The engineers involved

Decided not to do it that way

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.

3
The choice that mattered

They made it an open protocol, not a private fix

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

What sharing your password actually meant

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.

Claim on file "It only asked for read access."
Under the label There was no such thing A password doesn't come with a scope attached. "Read access" was a polite request the app could simply ignore — nothing technical enforced it. Full account access, every time, whether it was needed or not.
Claim on file "I can just revoke it later if I change my mind."
Under the label Only by changing your master password There was no per-app switch to flip. Cutting off one app meant changing the password everywhere — silently breaking every other legitimate app that also had it, whether you wanted to keep using them or not.
Claim on file "I trust this app, so it's fine."
Under the label Trust in the app isn't the whole risk Your real password now also lives in a second company's database. Trusting the app's intentions says nothing about trusting its security team, its breach history, or the intern with prod access.

03 · What we moved to

OAuth 2.0, then OIDC on top

Three revisions of the same idea: stop sharing passwords, issue scoped passes instead, then standardize who those passes belong to.

1
2007 – 2010

OAuth 1.0 (RFC 5849)

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.

2
2012

OAuth 2.0 (RFC 6749)

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.

3
2014

OpenID Connect — the gap OAuth 2.0 left open

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

Same protocol, internal reuse

Original use case

Third-party delegation

  • "Let this app read your calendar" / "post to your feed"
  • You (the "Resource Owner" in OAuth's own terms), the Client, and the Authorization Server are three genuinely different companies
  • What OAuth was built for on day one
Common extension

Single sign-on across your own apps

  • One login, many apps you happen to own — not a third party at all
  • Same token machinery, reused because it already solves "prove identity once, use it everywhere safely"
Client-shape driven

Public clients — mobile & SPA

  • No server to keep a secret in — anyone can read a mobile app's binary or a browser's JS
  • This is exactly why PKCE exists — covered properly in Concept 101 and the live handshake below
What we did

QLM + translator, same team

  • QLM (edu-web) runs the Authorization Server. The translator SPA is the client. Same company, but the roles are split on purpose.
  • Password touches one origin only. Tokens are scoped. Revocation is live. We didn't need a third-party IdP to get those guarantees.
  • The protocol doesn't care whether the two ends are strangers or coworkers. The separation is the point.
Claim on file "OAuth's job is to log you in."
Under the label Not originally, no OAuth's job is "let something act on your behalf, on a leash you can shorten anytime." Login-as-identity was OIDC's job, bolted on seven years later because everyone needed it too.

04 · Concept 101

Three roles, one handshake

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.

Authorization Server (AS)
The officer at the desk. The only party that ever sees the password. In this codebase, that's QLM (inside edu-web) — it runs the login page and issues codes and tokens.
Client
The app asking on your behalf — here, the translator SPA. It never sees your password, only a code and then a token, both handed to it after you've already authenticated elsewhere.
Resource Server (RS)
The place your token actually gets used — the translator backend's API routes. It doesn't trust the client's claim that "this user is fine" — it checks the token itself.
Authorization code
A short-lived, single-use ticket handed to the client via browser redirect after login. Not the token itself — it has to be exchanged for one, server-to-server, which is what stops it from being useful if intercepted.
id_token
A signed JWT that answers "who is this." Standardized claims (sub, iss, aud, nonce). This is the piece plain OAuth2 doesn't have — it's what makes it OIDC.
access_token
Answers "what can this token do," not "who is this." In this system it's opaque — a random string the Resource Server looks up in Redis rather than decodes.
PKCE
Proof Key for Code Exchange. The client generates a secret (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.
scope
What the token is allowed to be used for — e.g. translator. The Resource Server checks this on every request; having a valid token isn't enough if it lacks the right scope.
state / nonce
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

What changed in the token itself

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.)

// header.payload.signature — anyone can decode header+payload, it's just base64 alg: "HS256" // symmetric — same secret signs & verifies sub: "jdoe" tokenVersion: 3 // used for revocation — bumped to invalidate exp: 1753500000 // signed with process.env.JWT_KEY — default fallback "dev-secret" if unset
A

One secret, everywhere. Whatever process holds JWT_KEY can both mint and verify — issuing and checking aren't separated.

B

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.

C

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."

Honest caveat: this isn't insecure by itself — it's a legitimate, common pattern. What it doesn't give you is separation of duties, standardized identity, or delegated login to a third-party app.
// signed RS256 — asymmetric, verified with a public key anyone can fetch iss: "https://qlm.example/oidc" // which AS issued this aud: "edu-translator" // which client it's for — rejected if read elsewhere sub: "jdoe" nonce: "kx91..." // echoes what the client sent at login start exp: 1753500000
A

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.

B

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.

C

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.

Honest caveat: the id_token proves identity at login time. It is not what you send on API calls — that's the access token's job, and here they deliberately look nothing alike.
// what the client actually holds and sends as Bearer: "7f3a9e21-opaque-random-string-c88d" // meaningless on its own — decodes to nothing // what the Resource Server looks up server-side via AccessToken.find(): scope: "translator" enrollments: [...] // captured once, at issuance token_version: 3 // checked live, every request expiresAt: // enforced by the store itself
A

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.

B

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.

C

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.

Honest caveat: "opaque" isn't automatically "more secure" than a JWT access token — it's a different trade-off (instant revocation vs. no verification round-trip). Both are legitimate OIDC-compliant designs.

06 · Our migration in practice

The border crossing, step by step

This is the real path from the translator app to a usable API call, as we implemented it in edu-web + edu-translator.

1
Client — translator

Before redirecting anywhere, the client makes a secret

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.

2
Browser → AS

Full-page redirect to /oidc/auth

Not 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.

3
AS — QLM

QLM's own login page authenticates the user

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.

4
AS — QLM

findAccount attaches identity claims — once

Reads 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.

5
Browser → Client

Redirect back to /auth/callback with a code — not a token

The 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.

6
Client → AS (server-to-server)

Exchange the code for tokens — and prove it's the same client

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.

7
AS — QLM

Issues an id_token and an opaque access_token

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.

8
Client → RS

API calls carry the opaque access token as a bearer

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

Watch the handshake, one lane at a time

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.

You / Browser
Client — Translator SPA
Authorization Server — QLM
Resource Server — API
Step 0 / 0

08 · Booth game

You're the officer. Scan the evidence, call it.

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.

Booth session Score: 0 / 0

09 · What we left behind

Three ways to hand out access

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.

Where we were

App checks password, signs its own JWT

  • Client app touches the raw password directly
  • No separation between issuing and verifying
  • No standardized identity — every app invents its own claim shape
  • Revocation only works if you built a version-check yourself
Meh — OAuth2 without the identity layer

Delegated access, but "who is this" is informal

  • Code + token exchange, PKCE — real interception protection
  • But no standardized id_token, no nonce/aud guarantees
  • Every integration re-invents "how do I know who logged in"
Where we are now

Full OIDC between QLM and translator

  • AS/RS role separation — even when they're the same process
  • PKCE + state + nonce close the interception/replay/CSRF gaps
  • Standardized id_token; access token validated live, not just trusted
  • One documented trade-off admitted, not hidden: opaque tokens need a lookup, not free

10 · Part 1 recap

What the migration gave us

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 separation

A stolen authorization code is useless without the PKCE verifier that never left the client

PKCE

state proves the callback belongs to the request that started it — CSRF protection

state

nonce proves the id_token belongs to this browser session — replay protection

nonce

id_token is a standardized, signed identity claim — not a home-grown payload shape

id_token

Access tokens carry a scope the Resource Server checks — a valid token isn't automatically "allowed to do anything"

scope

Redirect URIs are allow-listed per registered client — the AS won't send a code anywhere else

registered client

Live checks (revocation, suspension) run per-request instead of being frozen into the token at login

live checks

Coming next

Parts 2 & 3

Part 1 was the migration and the handshake. The day-to-day questions came right after we shipped it.

Part 2 · coming soon

Tokens, tabs, and TTLs

Where do access tokens and refresh tokens actually live? Why not just use a long-lived cookie? How do multiple tabs share a session without fighting each other? What TTL did we pick for each, and why? That's the guide I'm writing next.

Part 3 · coming soon

Security knobs we turned on

PKCE, state, nonce, registered redirect URIs, disabled grant types, what each one closes, and what we'd still worry about if we got lazy. The booth game in Part 1 is a preview. Part 3 goes deeper on the threat model.