Authentication: tokens, rotation, and not owning what you don't have to
Auth is where a small team can waste months rebuilding what someone else already does better, and also where one careless decision leaks every account. Fable's answer draws a sharp line: let Firebase own identity issuance (phone OTP, Google) and never store a password or send an SMS — then mint its own two tokens on top, a short-lived stateless access JWT and a long-lived rotating refresh token. This chapter walks that design: why two tokens instead of one, how refresh rotation with reuse-detection defends a stolen token, how a stateless JWT gets near-instant revocation via a Redis set, and the O(n) refresh scan that quietly got slower with every new user.
Authentication: tokens, rotation, and not owning what you don't have to
Authentication is deceptively easy to start and genuinely hard to finish. Anyone can check a password and set a cookie; the hard parts are the ones that don't show up in the demo — sending OTP SMS reliably and fighting the fraud that follows, revoking a stolen session now instead of in an hour, rotating tokens so a leaked one is worthless, and doing all of it fast enough that every single API request (which has to authenticate) doesn't hit the database. Fable's auth design is a series of decisions about what not to build and which token does which job, and both are worth walking because they're the decisions every app faces and many get wrong.
Decision one: don't own identity issuance
The first and most consequential choice: Fable does not store passwords and does not send OTPs. Identity issuance — verifying you own a phone number via SMS OTP, or that you are a given Google account — is delegated to Firebase Authentication. The client authenticates with Firebase directly, receives a Firebase ID token, and hands that to Fable. Fable's job is only to verify that token (checking Firebase's signature) and, if valid, issue its own session tokens for the user it maps to.
Why give that away? Because owning OTP means owning the entire iceberg beneath it: SMS delivery across carriers, retry and rate-limit logic, the fraud and abuse that phone verification attracts, per-country deliverability, and the on-call burden when SMS stops arriving in one region. That's a whole product, and Firebase already runs it well. As the data-model chapter notes, this is why a planned otp_attempts table was never built — there was nothing to store, because Fable never sees an OTP. The principle generalizes past auth and echoes the infra chapter's "manage the system of record, don't own the reconstructable": don't build the commodity thing whose failure modes are someone else's core competency. Fable owns the thing that's its to own — the session and what it grants inside the app — and rents the rest.
The crucial boundary: Firebase authenticates who you are once; Fable's own tokens govern every request after that. Fable does not call Firebase on each API request — that would put a third party on its critical path. Firebase is the front door; Fable's tokens are the keys to the rooms.
Decision two: two tokens, because one can't do both jobs
After verifying the Firebase token, Fable issues two tokens, and understanding why it's two is the heart of modern auth:
- The access token — a short-lived (minutes) RS256-signed JWT. It's stateless: it carries the user id and session id in signed claims, so the auth guard can verify a request by checking the signature, issuer, and expiry — no database lookup. That's the point: every API request must authenticate, so authentication has to be nearly free, and a self-verifying signed token is how you avoid a database round-trip on every single call.
- The refresh token — a long-lived (60-day) 256-bit random string, stateful: one row per device sign-in in the
sessionstable, stored bcrypt-hashed. Its only job is to obtain new access tokens when they expire.
Why not one token? Because the two requirements are in direct tension. You want authentication to be stateless and fast (no DB per request) — which argues for a JWT. But a stateless token has a fatal property: it can't be un-issued. Once signed, a JWT is valid until it expires, no matter what — you can't reach out and cancel it. If you made it long-lived for convenience, a stolen token would be usable for its whole lifetime with no way to stop it. The two-token split resolves the tension: make the access token stateless and fast but short-lived (so the un-revocable window is minutes), and make the refresh token long-lived but stateful (a DB row you can revoke), used rarely. Fast auth on the hot path; real control on the cold one.
Rotation and reuse-detection: defusing a stolen refresh token
A 60-day refresh token is a juicy target, so Fable rotates it: every time it's used to get new access tokens, the old refresh token is invalidated and a brand-new one issued. A refresh token is thus single-use. This alone limits exposure — but it also enables a sharp theft defense: reuse detection.
If a refresh token that has already been rotated away is presented again, something is wrong — a legitimate client always holds the newest token, so an old one being used means it was captured and replayed (or cloned). Fable's response is deliberately aggressive: revoke the entire session. Both the attacker's stolen token and the victim's current one die, forcing a fresh sign-in (re-OTP through Firebase). The reasoning: a replayed old token is presumptive theft, and the safe move is to invalidate everything and make the real user re-authenticate, rather than let an attacker ride a cloned token. It's the standard refresh-rotation security model, and it turns a stolen refresh token from a 60-day liability into a self-tripping alarm.
Revoking the un-revocable: the Redis revocation set
Rotation handles refresh tokens, but the access JWT still has that un-cancellable window — short, but not zero. When you sign out a device or reuse-detection nukes a session, the already-issued access tokens for it remain cryptographically valid until they expire. Waiting minutes to honor a revocation is unacceptable for "sign out this stolen phone now."
The fix threads the needle without giving up stateless verification: a Redis revocation set. When a session is revoked, its id goes into a Redis revoked_sessions set with a TTL equal to the access-token lifetime. The auth guard, after verifying the JWT signature, does one fast Redis check — is this session id revoked? — and rejects it if so. The elegance is in the details: the check is a single in-memory Redis hit (not a database query), the TTL means the set only ever holds recently revoked sessions (it can't grow unboundedly, because once the access token would have expired anyway, the entry is pointless and evicts itself), and a Redis miss is safe — if Redis is momentarily unavailable, the worst case is a revoked token surviving the few remaining minutes until its natural expiry, which is exactly the guarantee you already had. It's a cache used for invalidation rather than for data: near-instant revocation bolted onto stateless tokens, at the cost of one Redis lookup per request.
Planned (TDD)
Fable owns OTP: an otp_attempts table of hashed codes, plus a single session token.
Shipped
Firebase owns OTP issuance (no otp_attempts table at all); Fable mints a short-lived RS256 access JWT + a rotating 60-day bcrypt-hashed refresh token, with reuse-detection, a Redis revocation set, and a SHA-256 lookup column so refresh doesn't scan every session.
Owning OTP means owning SMS delivery, retries, and fraud — Firebase already does that. And a single token can't be both statelessly-fast on every request and revocable — so the access/refresh split, plus rotation and a revocation cache, became necessary once auth met real security and scale.
A small, deliberate exception: the GET-only query-param token
One more decision worth seeing because it's about scoping a compromise. Normally the access token rides in the Authorization: Bearer header, never in the URL — tokens in URLs leak into server logs, browser history, and referrer headers. But native image loaders and some media fetchers can't set custom headers, so Fable allows the access token as an ?access_token= query parameter on GET requests only. The restriction is the point: a token that can only be used on a GET can't authorize a mutation even if it does leak from a URL, so the blast radius of the compromise is bounded to reads. It's the same signed JWT running the same verification and revocation checks — just a carefully fenced concession to how clients fetch images.
Interview takeaway
For any "design authentication / a session system / login" prompt:
- "I'd delegate identity issuance (OTP/SMS, social login) to a provider and only verify their token, so I never own SMS delivery or password storage." Signals you know where the real cost of auth hides.
- "Two tokens: a short-lived stateless JWT for fast per-request auth, and a long-lived stateful refresh token that's revocable." Then say the tension out loud — stateless means fast but un-revocable, so keep it short and pair it with a refresh token you can revoke.
- "Refresh tokens rotate on every use, and a replayed old token triggers reuse-detection that kills the whole session." High-signal: shows you defend the long-lived credential.
- "Instant revocation of a stateless token comes from a short-TTL revocation set in Redis that the auth check consults, where a cache miss fails safe."
- "Store the token bcrypt-hashed for at-rest safety, but index a fast SHA-256 of it for lookup — one value can't be both a secret and a key." The B-tree/security crossover point.
The series theme once more: the login screen was the easy part. The two-token split, rotation, reuse-detection, the revocation cache, and the SHA-lookup fix are what "auth" actually means in production, and every one of them is a response to a real tension — speed vs revocability, at-rest safety vs lookup speed, owning vs renting. Where these sessions and tokens physically live is the data model; the TLS lesson covers how the tokens stay secret on the wire.
Go deeper
- OAuth 2.0 Security Best Current Practice (RFC 9700) — The authoritative source on refresh-token rotation and reuse-detection as a defense against token theft — exactly the mechanism this chapter implements.
- JWT & signature verification (RS256) — Background on how a signed token is verified without a database — the property that makes the access token statelessly fast, and why that also makes it un-revocable.
- RFC 7519 — JSON Web Token (JWT) — The spec for the claims, signing, and expiry the access token relies on — what "stateless, self-verifying token" actually means at the byte level.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Fable neither stores passwords nor sends OTPs. Explain what Firebase does vs what Fable does, where the boundary is, and why Fable does not call Firebase on every API request.
- Why two tokens instead of one? State the tension between stateless-fast and revocable, and explain how making the access token short-lived-stateless and the refresh token long-lived-stateful resolves it.
- Refresh tokens rotate on every use. Explain reuse-detection: what does presenting an already-rotated token imply, and why is revoking the entire session the right response?
- A stateless JWT cannot be un-issued, yet Fable can sign out a device almost instantly. Explain the Redis revocation set: what has a TTL and why, and why a Redis miss is safe.
- Refresh got slower as the user base grew. Name the O(n) operation, explain why you cannot simply hash the incoming token and look up the bcrypt column, and describe the two-column fix.
- Fable allows the access token as a query parameter only on GET. Explain the risk of tokens in URLs and why restricting it to GET bounds the damage of a leak.
Data model: money you can trust and ids you can sort
How Fable's schema is built around two decisions most apps get wrong — never storing money as a float, and never using an id you can't generate on the client. This chapter walks the real Prisma schema: paisa-as-bigint with a currency-branded Minor type, ULIDs over auto-increment and UUIDv4, splits that always sum to the total, and the idempotency-key unique constraint that makes a retried payment write safe. Then the drift: 33 tables planned, 37 shipped, and a session-lookup column added after cold-start refresh got slow.
Real-time chat: the socket is delivery, the database is truth
A chat that feels instant is easy to fake and hard to make correct, because the mistake is treating the WebSocket as the system of record. Fable's chat does the opposite: every message is a durable Postgres row written first, then broadcast over Socket.IO as fast delivery — so a dropped socket, a tunnel, or a reconnect never loses a message, it just delays when you see it. This chapter walks that design: persist-then-broadcast, group and user rooms, the Redis pub/sub adapter that lets any server push to any client (the seam that makes horizontal scaling possible), message dedup for flaky-network resends, and why reconnect reconciles against the database, not the socket.