OIDC for people who've only ever built a password login

You have shipped a login before. A users table with a password_hash, a sessions table, a form that checks the hash and sets a cookie, a "forgot password" email, a "verify your address" email. It works. Now some service offers "Sign in with…", and every explanation you find either waves its hands ("it's like a valet key") or drops you straight into the OAuth 2.0 RFC. This post is for the gap in between: you can read code and you have built auth — you have just never delegated it.

So we will not start from the protocol. We will start from the login you already built, and go through it piece by piece, saying what each piece becomes. The one sentence to hold onto the whole way through:

Delegating identity does not replace your session. It replaces the part where you check a secret. Everything downstream of "this is the right user" stays exactly as you wrote it.

Your password_hash column → gone

This is the biggest change, so take it first. Today you store a hash and run the comparison yourself:

-- before: you hold the secret
users(id, email UNIQUE, password_hash, email_verified_at, ...)

When you delegate, the issuer (the thing behind the button — Google, Apple, or in our case ChirpAuth) authenticates the user. How it does that is its problem, not yours: Chirp Zero, for instance, emails a one-time link. Your app never sees, stores, or checks a credential. So:

-- after: you hold an identifier, never a secret
users(id, sub UNIQUE, ...)

Notice what just left the building. There is no password column, so there is no password column to leak. The entire category of incident that starts with "our credential database was exfiltrated" does not apply to a table that has no credentials in it. You did not harden that risk away — you deleted it.

Your login endpoint → a redirect and a callback

Your old POST /login looked up the user, verified the hash, and set a cookie. In its place you have two smaller endpoints and zero password logic:

You are not checking anything secret here. You are starting a conversation with the issuer and receiving its answer. (The exact parameters on the wire — state, nonce, PKCE, the redirect — are a topic of their own; you do not need them to follow this post.)

The signed token → NOT your session

Here is the mistake almost everyone makes coming from password-land, so it gets its own section. The thing the issuer returns — the id_tokenis not a session cookie. It is a one-time, short-lived, signed proof that a sign-in happened just now. It expires in minutes. It is not meant to ride along on every request.

What you do with it is the part that surprises people: you verify it, read one identifier out of it, and then you do exactly what you did before.

GET /auth/callback?code=…&state=…
  assert state == what_you_stored          // it was your request
  id_token = POST issuer/token (code, client_id, pkce_verifier)
  claims   = verify(id_token)              // signature, iss, aud, exp, nonce
  user     = users.upsert(sub = claims.sub)
  set_cookie(create_session(user))         // <-- your old code, untouched

That last line is your existing session machinery, unchanged. The token's job ends at "yes, this is user X, present right now," and then it is thrown away. Your sessions table, your cookie settings, your logout — all of it stays. If you remember nothing else: the token logs nobody in. It vouches, once, and you take it from there.

Verifying the token correctly is the one genuinely fiddly part, and it is easy to get subtly wrong (accepting the wrong signing algorithm, skipping the audience check). That is exactly what our four-language conformance suite exists to pin down — so in practice you call a verified library, not your own crypto.

Your email primary key → a stable sub

You probably keyed users on email, or put UNIQUE on it. Two bugs you may have already hit: people change their email, and people mistype it. With OIDC you key on the sub claim instead — an identifier the issuer guarantees is the same person on every sign-in, regardless of what their email does later.

With Chirp specifically, that sub is pairwise: it is derived from the user's root identity and your client_id, so your app gets one stable id per user, and a different app gets a different id for the same person. You store sub. Identity stops being tangled up with a mutable address.

The honest part: Chirp never sends your app the user's email — there is no email claim and no email scope. If you used email as your identifier, key on sub and the email-change problem disappears for free. If you used it to contact the user — receipts, notifications — that is now a deliberate decision you make separately, because the address no longer arrives at your back door as a side effect of login. For many apps that is a relief; if it is not, know it up front.

The flows that simply disappear

Delete these, and the bugs in them go too:

What you are now responsible for, and what you gave up

It would be dishonest to make this sound free, so here is the ledger.

New responsibility: verify the token properly (above) — it is the one place a mistake is a real vulnerability rather than a 500.

New dependency: when the issuer is down, your users cannot sign in. You have traded "I run authentication" for "I depend on someone who does" — the same trade you already made for your database host, but worth naming out loud rather than discovering during an outage.

Lock-in, stated plainly: pairwise subs do not port between issuers, so switching issuers later means re-mapping your users. Our answer to that is that ChirpAuth is AGPL-3.0 and self-hostable (Lambda + DynamoDB + SES), so "the issuer" is allowed to be you — but that is a real cost to weigh, not a footnote.

What you gave up: the ability to see the user's password (you are glad to) and their email (you may or may not be). That is the whole price.


The model that trips up everyone arriving from password login is imagining the token logs the user in. It does not. It speaks one sentence — "this is user X, present right now" — and hands the rest back to code you already wrote. Delete the hash column, key on sub, keep your sessions. That is most of the migration.

Want the non-technical version to send a non-engineer? "What 'Sign in with…' actually does".