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:
/loginredirects the browser to the issuer, carrying yourclient_idand a few random values your server remembers for a moment./callbackreceives a one-timecodeback from the issuer. Your server hands that code to the issuer's token endpoint and gets a signed statement in return.
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_token — is
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:
- Forgot-password / reset. No password, no reset.
- Password rules, breach checks, lockout-after-N-attempts, credential-stuffing defense. All of that protected a secret you no longer hold.
- "Verify your email." The issuer already established the user controls the address — that is how Chirp Zero signed them in — so you are not sending your own confirmation mail.
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".