devShakib

How I Do Authentication Without Rolling My Own Auth

Don't roll your own auth—but a managed provider isn't the whole job. My framework for Firebase Auth sessions, JWT revocation, MFA, custom claims, and recovery.

The best security advice I ever got fit on a sticky note: "You are not going to out-engineer a team of a hundred people whose only job is login." That's why I don't roll my own auth. But "don't roll your own auth" is one of those slogans that's completely right and quietly misleading, because it makes juniors think authentication is a box you buy and bolt on, and then you're done.

You're not done. Outsourcing identity moves the hard problems around; it doesn't delete them. A couple of years back I inherited a Firebase Auth setup on a client project where everything "worked" — users logged in, tokens flowed, the login screen was pixel-perfect — and it still had a password-reset flow that would hand a motivated attacker other people's accounts. The provider was flawless. The 200 lines of glue we owned were not. This post is the decision framework I actually use: what to hand off to a managed authentication provider, what to keep, and how to tell the two apart.

Why "don't roll your own auth" is right but incomplete

Rolling your own auth means writing the crypto and the credential storage yourself: password hashing, timing-safe comparisons, token signing, the session store, TOTP validation windows, rate limiting on the login endpoint. This is a genuinely brutal domain where every mistake is a CVE and the failure mode is silent. You don't find out you got the bcrypt cost factor wrong, leaked a timing side-channel, or forgot to salt until someone dumps your database and the internet finds out for you.

Handing this to a managed identity provider — Firebase Authentication, Auth0, Amazon Cognito, Clerk, or Supabase Auth — is almost always the correct call. I've never once regretted delegating credential verification. These teams live and breathe OWASP guidance, run bug bounties, and patch the classes of bug you'd never think to test.

But identity is bigger than the login box. The provider owns credential verification. It does not own:

Every one of those lives in your code, your security rules, and your product decisions. So the real skill isn't "use a provider." It's knowing exactly where the provider's responsibility ends and yours begins, and not fumbling the handoff. That handoff — the seam between authentication and everything downstream of it — is where I've watched otherwise solid apps spring leaks.

What a managed auth provider gives you, and what it quietly doesn't

Here's the honest split for Firebase Auth, which is what I reach for most because it's free at the scale most of my projects run and it plugs straight into the rest of the Google Cloud stack. The same mental model maps cleanly onto Auth0, Cognito, and Supabase — only the API names change.

What you get, and should be grateful for:

What it quietly does not give you:

The trap is treating the ID token as a login session. It isn't. It's a short-lived claim about identity that your backend re-verifies on every call. And "verify" is doing real work in that sentence — decoding a JWT is not verifying it. A decoded token is just base64 that anyone can forge in a text editor.

// Backend: verify the ID token on EVERY request. Never trust a decoded// JWT you didn't verify — a decoded token is just a base64 string.import { getAuth } from "firebase-admin/auth";async function requireUser(req: Request): Promise<DecodedIdToken> {  const header = req.headers.get("authorization") ?? "";  const token = header.startsWith("Bearer ") ? header.slice(7) : null;  if (!token) throw new HttpError(401, "missing token");  // checkRevoked = true costs a lookup but honors forced sign-out.  return getAuth().verifyIdToken(token, /* checkRevoked */ true);}

That checkRevoked flag is the difference between "user is signed out everywhere in a few minutes" and "user is signed out everywhere in up to an hour." Know which one you shipped, and make it a deliberate choice rather than a default you never read.

Session and token lifetime: the decisions people get wrong

This is where I see the most confident mistakes, so let me be blunt about the model.

Firebase gives you two things: a short-lived ID token (one hour, non-negotiable) and a long-lived refresh token (effectively durable) that the client SDK uses to silently mint new ID tokens. The refresh token is your session. The ID token is a disposable proof you attach to each request. Get those two roles straight in your head and half of the confusion around JWT expiry evaporates.

The mistake people make: they treat the one-hour expiry as a security boundary. It isn't, really. If you need to kill a session right now — the user hit "log out everywhere," or you detected a compromised account — the ID token stays valid for up to an hour unless you actively check revocation. So split your endpoints by blast radius:

Prefer session cookies over localStorage for web apps

For web apps specifically, I strongly prefer session cookies over shipping ID tokens to the browser and stashing them in localStorage. Firebase has a first-class API for this: you trade the ID token for an HttpOnly, Secure, SameSite cookie your JavaScript can't read, which shuts down a whole class of XSS token-theft attacks. If an attacker's injected script can't read the credential, it can't exfiltrate it.

// Exchange a freshly-minted ID token for a session cookie.// Keep the window tight for a "remember me = no" experience,// longer if the product genuinely wants persistent login.const expiresIn = 60 * 60 * 24 * 5 * 1000; // 5 daysconst sessionCookie = await getAuth().createSessionCookie(idToken, { expiresIn });res.cookie("session", sessionCookie, {  maxAge: expiresIn,  httpOnly: true,  secure: true,  sameSite: "lax",});

My defaults, learned the boring way:

One edge case worth calling out: silent token refresh only works while the client can reach the auth servers. If a user goes offline mid-session, the ID token expires and the SDK can't mint a new one until connectivity returns — so any offline-capable app needs to tolerate a stale-but-not-yet-refreshed token gracefully rather than bouncing the user to a login screen.

Account recovery is where breaches actually happen

Attackers rarely brute-force a well-hashed password. They walk in through recovery. The password reset flow, the "verify your email" flow, the "I lost my 2FA device" flow — that's the soft underbelly, and it's almost entirely code you own even with a managed provider.

The client project I mentioned at the top had exactly this hole. The reset worked, but:

Fixing recovery is unglamorous and high-leverage. My checklist:

async function onPasswordChanged(uid: string) {  // Kill every outstanding refresh token; ID tokens die within the hour,  // or immediately anywhere you verify with checkRevoked = true.  await getAuth().revokeRefreshTokens(uid);  await sendSecurityEmail(uid, "Your password was changed");}

If you harden one thing after reading this, harden recovery. It's where the money leaks out, and it's the part no provider hardens for you.

MFA that users won't rage-quit

Multi-factor authentication is the highest-ROI security control there is, and also the one users hate most, so the entire game is friction management. Get the friction wrong and people either disable it or, worse, never turn it on. A second factor that nobody enrolls in protects nobody.

My ordering, best experience to worst:

The friction rules that keep MFA from getting ripped out:

Firebase, via Identity Platform, supports TOTP and SMS enrollment natively, and the WebAuthn ecosystem has matured enough that passkeys are a real option now, not a science project.

The custom-claims and authorization boundary

Here's the rule I tattoo on every backend: the token proves who you are; it never decides what you can do without your code saying so. Authentication and authorization are different jobs, and blurring them is how you get privilege-escalation bugs.

Firebase lets you attach custom claims to a user — small bits of data (role, tenant, plan) baked into the ID token. This is great for coarse-grained decisions because it means your security rules and backend can authorize without a database read, which keeps hot paths fast.

// Set once, server-side only, when a role changes.await getAuth().setCustomUserClaims(uid, { role: "admin", tenant: "acme" });
// Firestore rules can then authorize straight off the verified token.match /tenants/{tenant}/docs/{doc} {  allow read, write: if request.auth.token.tenant == tenant                     && request.auth.token.role in ["admin", "editor"];}

The sharp edges, all of which I've been bitten by:

The mental model: the token gets you through the front door. Every room still checks the badge.

Migrating off a provider later without a big-bang rewrite

The fear that keeps people writing their own auth is lock-in. Legitimate fear, wrong solution. You reduce vendor lock-in with architecture, not by hand-rolling scrypt.

I keep the provider at arm's length behind a thin internal interface. My app code never calls the Firebase SDK directly for identity decisions; it calls my IdentityService, and that adapter is the only thing that knows a provider exists.

abstract class IdentityService {  Future<AppUser?> currentUser();  Stream<AppUser?> authState();  Future<AppUser> signIn(Credentials creds);  Future<void> signOut();}// FirebaseIdentityService implements this today.// A future adapter can implement the same contract with zero UI changes.

When you do need to migrate — provider pricing changed, a client demands data residency, whatever — the playbook is boring and safe:

I've never done a big-bang auth cutover and I never will. The whole point of outsourcing identity is that it should be swappable, and it is — if you didn't spray provider calls across your codebase.

Key takeaways

Outsourcing auth is right. Thinking it means you're off the hook is the mistake. Hand off the parts that are somebody else's core competency, and own the seam — because the seam is where the breaches are.