← back to posts

$ posts/auth-from-the-ground-up.md

---
date:  2026-09-23
tag:   Technical
read:  8 min
---

Auth from the ground up: the mental model I wish I'd been handed

Every piece of authentication machinery (cookies, tokens, JWTs, OAuth, middleware, row-level security) exists because HTTP is stateless and someone has to answer two questions on every request: who are you (authentication) and what are you allowed to do (authorization). Nobody taught me auth in that order, I assembled this mental model myself while building aloud, and this is the version I wish someone had handed me.

Keep the two questions separate. A bouncer checking your ID is authentication. The VIP list deciding whether you get past the rope is authorization. “No user can read another user’s data” is an authorization rule that depends on authentication having happened first.

Login and session are different problems

Every request arrives at your server as a stranger, so all auth splits into two phases:

PHASE 1: LOGIN (once, expensive)          PHASE 2: SESSION (every request, cheap)
prove identity via one of:                carry a credential minted at login:
  something you know   (password)           the "wristband": the bouncer
  something you have   (phone, inbox)       doesn't re-check your ID every
  something you are    (fingerprint)        time you walk to the bar

Every scheme you’ve heard of is a phase-1 choice. A magic link is “something you have” (the inbox); “Sign in with Google” outsources the proof to someone who already did it. And every phase-1 choice mints the same thing afterward: a session credential.

The fork everything else falls out of: where does the truth live?

There are exactly two ways to build the wristband.

Option A: server-side session (stateful). Generate a random meaningless string, store token = user 42 in Redis or Postgres, hand the string to the client. Every request is a lookup. Instantly revocable: delete the row and the user is gone on the very next request. The string needs no encoding or encryption, because it has no contents; its entire meaning lives server-side.

Option B: self-contained token (stateless). Write the facts down (“user 42, expires 3 pm”) and cryptographically sign the note so it can’t be forged. That’s a JWT. Verification is local math, microseconds, no lookup. The price: you can’t easily un-issue it before it expires, which is why access tokens are kept short-lived (~1 hour) with a long-lived refresh token to mint new ones.

The honest decision rule, which took me embarrassingly long to find: JWT solves a distribution problem, not a performance problem. A Redis lookup is ~1ms, so speed was never the argument. But a Redis session requires every verifier to reach the same Redis. When the thing minting tokens (Supabase, Firebase, your auth service) and the thing verifying them (your API) are different systems in different clouds, there is no shared database and never could be. The trust has to travel inside the token. One backend and self-run auth? Server-side sessions are arguably better. Auth outsourced, or many services verifying independently? JWT.

In aloud, the stateless tradeoff is visible in production behavior. Disabling a user from the admin page sets the Firebase disabled flag and revokes their refresh tokens: new sessions are blocked immediately, because session start does one deliberate stateful check against Firebase, but an already-issued access token keeps working until it expires, so their other API access dies within the hour. That one-hour tail is exactly the revocation gap this section describes, accepted knowingly instead of discovered later.

(Cookies vs tokens is a false dichotomy, by the way. A cookie is just one pocket the credential rides in, with auto-attach and HttpOnly protection; the Authorization: Bearer header is the other pocket. The real choices are what kind of token, and which pocket.)

Reading a JWT: integrity without secrecy

eyJhbGciOiJSUzI1NiJ9 . eyJzdWIiOiJ1c2VyLTQyIiwiZXhwIjoxNzYwMDAwMDAwfQ . dGhlX3NpZ25hdHVyZQ
      header                        payload                                signature
  {"alg":"RS256",              {"sub":"user-42",                    sign(key, header + "." + payload)
   "kid":"key-A"}               "iss":"...", "aud":"my-app",
                                "iat":..., "exp":...}

The first two chunks are base64: encoding, not encryption. Anyone can read them, so never put secrets in a payload. What the signature guarantees is integrity. It’s a fingerprint of those exact bytes, producible only with the key. Change "sub": "user-42" to "sub": "user-1" and you now need a signature you cannot compute, brute-force (2^256), or borrow from another token. A laminated ID badge: everyone can read it, nobody can alter it.

That framing dissolves the classic confusion, “if anyone can read it, can’t they steal it?” Yes, and that’s a different threat with a different defense. Forgery (minting or altering tokens) is blocked by the signature. Theft is handled elsewhere: TLS encrypts the wire so there’s nothing to sniff, HttpOnly cookies keep injected JavaScript’s hands off it, and short expiry turns a stolen token into a melting ice cube. A JWT is cash. Uncounterfeitable, not unstealable.

Two smaller pieces complete the picture. Keys: HMAC (one shared secret) means every verifier can also forge, which is fine inside one trust boundary; RS256 (private key signs, public key verifies) means the secret exists in exactly one place and the thing distributed everywhere is worthless to attackers. That’s what Supabase and Firebase use, publishing their public keys at a JWKS URL (JSON Web Key Set, the well-known address where a provider’s current verification keys live, so verifiers can fetch and cache them). Rotation: every token’s header names its key (kid), and the JWKS publishes old and new keys side by side until old tokens expire, so rotation breaks no live sessions.

Where verification runs: one seam, verified in-process

The architecture rule that made everything else swappable: repo functions take a user_id: str and neither know nor care where it came from. Auth’s entire job is producing that verified user_id at the HTTP boundary, in one dependency function:

auth provider ──▶ get_current_user_id() ──▶ routes / agent tools ──▶ repositories
 (swappable)      the ONLY auth-aware        pass user_id through     WHERE user_id = ...
                  code in the backend                                 (never changes)

And that function runs in the backend, always. Never “the frontend already checked.” The trap I asked about myself: isn’t the backend only reachable through the frontend? Usually that’s not even true (in aloud, the browser calls FastAPI directly), and even behind a gateway, a trusted x-user-id header means every perimeter bug becomes cross-user data access. The 2025 Next.js middleware bypass (CVE-2025-29927) made this concrete: one crafted header skipped middleware auth entirely. Catastrophic where the frontend was the only checkpoint; a non-event where the backend independently verifies a signature. Verification is deterministic math. Network posture is a probability that degrades with every config change.

The last line of defense is authorization pushed into the database itself: row-level security, where Postgres refuses to return rows whose user_id doesn’t match the session, no matter how buggy the SQL above it. Repo scoping is the librarian who only fetches your books; RLS is the shelves locking against the wrong card. The infamous wave of exposed Supabase apps in 2025 was thousands of apps with no librarian and unlocked shelves. A misconfiguration class, not an auth CVE.

For aloud I applied this with a dedicated aloud_app Postgres role and a user_scoped_session(user_id) helper that every transaction goes through, so RLS is enforced underneath even a query that forgets its WHERE. Admin dashboards need cross-user reads, and those exist only behind a separate session helper that is structurally admin-gated, database-enforced read-only, and blind to content tables entirely: an admin can see usage and latency, never transcripts. The one legitimate RLS bypass, a boot-time cleanup sweep, runs on a bootstrap engine that the app retires right after startup, so request-path code cannot reach it even by mistake. Confinement by structure, not by convention.

The payoff: provider choice becomes a config decision

With the model in place, comparing providers stops being a feature-list fight and becomes one question: what kind of credential does it mint, and can my backend verify it independently?

  • Auth.js (NextAuth) mints an encrypted JWE meant to be opened by the same Next.js app that sealed it. Perfect for a Next.js monolith; for my Python backend it means re-implementing undocumented crypto internals. Wrong shape for a split stack.
  • Supabase Auth mints standard signed JWTs. Ten lines of pyjwt against its JWKS and the backend never contacts Supabase at all. Right shape.
  • Firebase Auth, what aloud shipped, is the same right shape plus a broker: a user database, an OIDC federation layer over Google and other identity providers, and an RS256 token minter. The decision came down to three things: third-party identity provider support was wanted from day one, standard JWT verification fits a separate FastAPI backend, and it’s free at this scale. firebase-admin verifies the ID token in that one dependency function; verification checks the signature, exp, iss, and aud, and skipping any of them is a real vulnerability class (Supabase’s 2026 CVE was a skipped issuer check).

One more Firebase pattern worth stealing: admin access in aloud is a custom claim on the token (admin: true), set by a local script through the service account. It’s bound to the user id rather than an email match, so renaming an email can’t confer admin, and there’s zero admin configuration living in the repo, env, or database. Authorization data riding inside the authentication credential, verified by the same one function.

Swapping between these touches exactly one function. That’s the whole point of the seam.

The compressed version, for the next person assembling this from scattered docs: login proves identity once, expensively; a session is the cheap credential minted from that proof; tokens are how sessions travel, and cookies are just a pocket; signatures are why tokens can be trusted without a database lookup; JWT is signed-readable, JWE is encrypted; JWKS is how verification keys are published; the backend verifies every request itself in one seam; and RLS is authorization’s last line, inside the database. Everything else is a policy choice layered on top.

EOF · back to posts