Authentication is one of the most critical layers in a modern web app. Get it right and users barely notice it. Get it wrong and you ship a security incident — sometimes one that doesn't surface for months. Most teams reach for Firebase Authentication because it removes the worst parts of building auth from scratch: password hashing, OAuth, session lifecycle, account recovery. What it doesn't remove is the responsibility to understand what's actually happening when a user signs in.
Most developers I've worked with treat Firebase Auth as a black box. They drop in signInWithEmailAndPassword, see a logged-in user, and move on. The downstream cost is measurable: token misuse, broken session handling on refresh, permission checks that live on the client, security rules that allow everything because nobody wanted to debug them in staging. This post is the article I wish I'd read before shipping our first Firebase-backed product.
We'll cover what Firebase Auth actually does behind the sign-in button, the anatomy of a JWT, how refresh tokens keep users signed in, the three persistence modes and when to use each, how security rules and custom claims fit together, the seven mistakes I see in production codebases, and what changes when you scale auth across a Next.js app or an edge runtime. Every code snippet is something you can lift directly into your project.
1 What Firebase Auth Actually Does
At a high level, Firebase Authentication is a managed service that handles five jobs you would otherwise build yourself: user identity storage, sign-in providers (email, Google, GitHub, Apple, etc.), session management, JWT issuance, and the verification APIs your backend needs to trust those JWTs.
When a user signs in, Firebase produces two artifacts:
- An ID token — a short-lived JWT (~1 hour) that carries the user's identity claims.
- A refresh token — a long-lived credential the client SDK uses to silently mint new ID tokens when the current one expires.
Your frontend sends the ID token on each authenticated request. Your backend verifies it. Passwords never touch your database. That separation is most of what makes Firebase Auth worth using.
2 The End-to-End Architecture
Here's the shape every Firebase Auth flow follows:
User
↓ enters credentials
Frontend App
↓ signInWithEmailAndPassword / signInWithPopup
Firebase Authentication
↓ validates, generates tokens
ID Token (JWT) + Refresh Token
↓ stored by client SDK
Authenticated Requests
↓ Authorization: Bearer <ID token>
Your Backend
↓ verifyIdToken()
Access GrantedThe detail most tutorials skip: the frontend never talks to your database directly with credentials. Firebase validates, Firebase signs, and your app trusts only the signed token — not anything the browser claims.
3 Sign-In Providers
Firebase supports nine sign-in methods out of the box:
- Email / password
- Google, GitHub, Facebook, Apple, Twitter/X
- Phone (SMS OTP)
- Anonymous (guest sessions you can later upgrade)
- Custom (your own auth backed by a custom token)
The simplest is email/password — useful as a baseline because every other provider produces the same shape of result:
import { signInWithEmailAndPassword } from "firebase/auth";
const credential = await signInWithEmailAndPassword(
auth,
email,
password
);
console.log(credential.user.uid);
// "VlS3xN9...QjzA1" — Firebase-generated UIDInternally, Firebase verifies the credentials, opens a session, and the SDK starts emitting auth-state events that your app listens to.
4 Anatomy of a JWT
Every Firebase ID token is a JSON Web Token — three base64url-encoded segments separated by dots:
HEADER.PAYLOAD.SIGNATURE
eyJhbGciOiJSUzI1NiIs... .eyJ1aWQiOiJ1c2VyMTIz... .RKHb6q9xZ...Header — describes the signing algorithm. Firebase uses RS256 (RSA + SHA-256):
{
"alg": "RS256",
"typ": "JWT",
"kid": "abc123..." // ID of the public key used to verify
}Payload — the “claims” about the user, plus standard JWT timestamps:
{
"iss": "https://securetoken.google.com/your-project-id",
"aud": "your-project-id",
"auth_time": 1710000000,
"user_id": "VlS3xN9...QjzA1",
"sub": "VlS3xN9...QjzA1",
"iat": 1710000000,
"exp": 1710003600, // ~1 hour later
"email": "alex@example.com",
"email_verified": true,
"firebase": {
"identities": { "email": ["alex@example.com"] },
"sign_in_provider": "password"
}
}Signature — the proof. Firebase signs the header+payload with its private key. Your backend (or Firebase's own services) verifies it with the matching public key. Without the signature step, anyone could craft a fake token.
5 The Token Flow, End to End
Once the SDK has an ID token, your frontend includes it on every authenticated request. The conventional header shape:
// On the client
const token = await auth.currentUser.getIdToken();
const res = await fetch("/api/projects", {
headers: {
Authorization: `Bearer ${token}`,
},
});On the server, you verify the token before doing anything else. The Firebase Admin SDK takes care of fetching the right public key and validating the signature, expiry, audience, and issuer:
// Server-side (Node, Next.js API route, Cloud Function)
import admin from "firebase-admin";
export async function authenticate(req) {
const header = req.headers.authorization || "";
const token = header.replace(/^Bearer\s/, "");
try {
const decoded = await admin.auth().verifyIdToken(token);
return decoded; // { uid, email, ...claims }
} catch (err) {
throw new Error("Invalid or expired token");
}
}Three things to internalise: verifyIdToken is cheap, but not free; you should cache the result for the lifetime of the request; and you must always call it — never trust the uid the client claims in a body field.
6 Session Persistence — Three Modes, One Decision
Where the SDK stores the auth state controls the user's experience after a refresh. Firebase exposes three modes:
browserLocalPersistence— survives browser restarts. The default for most apps.browserSessionPersistence— survives page refresh, dies when the tab closes. Good for sensitive flows on shared computers.inMemoryPersistence— dies on any reload. Use for kiosks, short OTP-style sessions, or high-security ops portals.
import {
setPersistence,
browserLocalPersistence,
} from "firebase/auth";
await setPersistence(auth, browserLocalPersistence);
// Now subsequent sign-ins persist across browser restarts.The mistake I see most often is picking the default without thinking about it. A banking dashboard should not share a persistence strategy with a public blog. Treat this as a security decision, not a default.
7 Why Refresh Tokens Exist
ID tokens last about one hour. If they lasted forever, a stolen token would be a forever account takeover. If they expired in five minutes, users would re-login constantly. Refresh tokens are the compromise: long-lived (think weeks), but used only by the client SDK to silently mint a new ID token when the old one is about to expire.
ID Token expires (~1 hour after issue)
↓
Firebase SDK detects expiry
↓
SDK calls Google's secure-token endpoint with refresh token
↓
New ID token issued
↓
User session continues — no logoutYou almost never touch the refresh token directly. The exception is when you need to invalidate all sessions for a user (password change, account compromise, suspicious activity):
await admin.auth().revokeRefreshTokens(uid);
// Subsequent verifyIdToken(token, true) calls will throw.Pass checkRevoked: true to verifyIdToken on sensitive routes — without it, an already-issued ID token remains valid until its natural expiry, even after revocation.
8 OAuth Providers — Google and GitHub
Social logins follow the same OAuth dance regardless of provider. Google:
import {
GoogleAuthProvider,
signInWithPopup,
} from "firebase/auth";
const provider = new GoogleAuthProvider();
provider.addScope("profile");
provider.addScope("email");
const result = await signInWithPopup(auth, provider);
console.log(result.user.email);And GitHub is structurally identical:
import {
GithubAuthProvider,
signInWithPopup,
} from "firebase/auth";
const provider = new GithubAuthProvider();
provider.addScope("read:user");
const result = await signInWithPopup(auth, provider);Internally the flow is:
User clicks Sign in with Google
↓
Provider OAuth screen opens
↓
User approves the requested scopes
↓
Provider sends an OAuth credential back
↓
Firebase exchanges it for its own ID + refresh tokens
↓
User is authenticatedAlways request minimum scopes. Asking for repo access when you only need a username is a trust-killer in the consent screen and a real liability if your token store is compromised. This principle is called least privilege access and it's the single cheapest security win.
9 Security Rules — The Half of Auth Everyone Forgets
Authentication answers who is this? Authorization answers what can they do? For Firestore and Realtime Database, the authorization layer lives in security rules — declarative checks that Google evaluates on every read and write.
A well-written rule for “users can only read and write their own document”:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, write:
if request.auth != null
&& request.auth.uid == userId;
}
match /posts/{postId} {
allow read: if true; // public read
allow create: if request.auth != null;
allow update, delete:
if request.auth.uid == resource.data.authorUid; // only the author
}
}
}The rule I have seen ship to production more times than I can count, usually because it was the “quick fix to unblock staging”:
// DANGER — every read and write on the database is now public.
allow read, write: if true;Thousands of Firebase projects have leaked data through exactly this rule. Treat the rules file like production code: review it, test it, version-control it, and run the Firebase emulator suite against it in CI.
10 Custom Claims and Role Systems
Custom claims let you attach arbitrary key-value data to a user's JWT — most commonly roles. Because the claim is baked into the signed token, security rules and your backend can both read it without an extra database lookup.
// Server-side, via the Admin SDK
await admin.auth().setCustomUserClaims(uid, {
admin: true,
plan: "pro",
});
// The next time this user's client refreshes their ID token,
// the new claims appear in the JWT payload.Then in your security rules:
match /admin-only/{doc} {
allow read, write: if request.auth.token.admin == true;
}Two gotchas: custom claims do not appear in the token until it is refreshed (call user.getIdToken(true) on the client to force a refresh), and the claim payload is limited to 1000 bytes. Use it for role flags, not for caching user preferences.
11 The Seven Production Mistakes
1. Trusting client-side checks
This pattern looks innocent and is catastrophic:
// Bad — the client can lie about isAdmin.
if (user.isAdmin) {
await fetch("/api/delete-everything", { method: "POST" });
}Show or hide UI based on the client claim by all means. But every privileged route must re-verify on the server using the decoded token's actual claims.
2. Putting secrets in the frontend bundle
Anything that ships to the browser is public. Service account JSON, Admin SDK credentials, third-party API keys with broad scopes — none of these belong in NEXT_PUBLIC_* variables or anywhere a bundler can reach them.
3. Wide-open security rules in production
Covered above. The default new-project rule has a two-line warning telling you to change it. Change it.
4. Skipping backend token verification
If the client tells you uid: "admin" in a request body and your server believes it, you have no auth — you have UI. Every authenticated route runs through verifyIdToken.
5. Silent catch blocks
// Bad
try { await signIn(...); } catch (e) {}
// Better
try {
await signIn(...);
} catch (err) {
logger.error({ err }, "Sign-in failed");
showToast("Sign-in failed. Please try again.");
}A swallowed auth error becomes “the login button silently does nothing” in a support ticket.
6. Caching ID tokens past their expiry
Some teams cache the result of getIdToken() in a Redux slice and never refresh it. Always use auth.currentUser.getIdToken() at the point of use — the SDK handles refresh.
7. No rate limiting on auth endpoints
Firebase has some built-in protection, but if your signup flow involves your own API (e.g. a username reservation step), you need explicit limits. Credential-stuffing bots hit endpoints at thousands of requests per minute.
12 Scaling Token Verification
A single verifyIdToken call is fast — a signature check against a cached public key. At scale, “single fast call” becomes millions of fast calls and a noticeable share of your CPU budget. Three common moves:
- Cache the decoded result for the lifetime of the current request. Verifying once per request, not once per database call, is usually enough.
- Move verification to the edge — Cloudflare Workers, Vercel Edge, or Next.js middleware — so unauthenticated traffic never reaches your origin.
- For high-throughput services, set
checkRevoked: falseon the hot path and reserve revocation checks for sensitive operations (account changes, money movement).
13 Firebase Auth in Next.js
Server-side rendering complicates auth because the server needs to know who the user is before producing HTML. Two patterns work well:
Pattern A — session cookies. Use the Admin SDK to mint a long-lived session cookie from the ID token, set it on a secure HTTP-only cookie, and verify on every request:
// On the server, after the client posts its ID token
const sessionCookie = await admin
.auth()
.createSessionCookie(idToken, {
expiresIn: 60 * 60 * 24 * 5 * 1000, // 5 days
});
response.cookies.set("session", sessionCookie, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 5,
});Pattern B — middleware verification. In middleware.ts, verify the session cookie on each request and attach the user to request headers so route handlers can read them without re-verifying.
Either pattern lets you protect server components and API routes consistently. The mistake to avoid is doing auth purely on the client — Next.js will happily render a “protected” page server-side before the client-side check runs, and the source HTML will leak whatever was on that page.
14 Monitoring, Admin SDK, and MFA
Once auth is live, you need to see it. Track at minimum:
- Failed sign-ins per IP and per account
- Token verification failures (almost always a deploy issue when they spike)
- New-device sign-in events
- Custom-claim updates (privileged action)
The Admin SDK is the single most powerful tool in your auth toolbox and must be guarded accordingly:
// Server-only — NEVER ship the service account key to the browser.
import admin from "firebase-admin";
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
// What it can do:
await admin.auth().createUser({ email, password });
await admin.auth().setCustomUserClaims(uid, { admin: true });
await admin.auth().revokeRefreshTokens(uid);
await admin.auth().deleteUser(uid);Finally — turn on MFA for any account with admin powers. Firebase supports SMS and TOTP authenticator apps. The five minutes of setup is worth more than every other piece of advice in this article combined.
Firebase Auth or Custom? A Quick Decision Guide
Choose Firebase Auth when you want speed, secure defaults, built-in OAuth providers, and a smaller backend surface. For 95% of startups, indie products, and internal tools, this is the right call.
Reach for a custom auth stack when you have strict compliance requirements (FedRAMP, certain HIPAA configurations), need fine-grained control over token issuance, or already have an established identity provider you must integrate into. The bar is higher than most teams realise: you are now responsible for everything Firebase was doing silently.
Frequently Asked Questions
- What exactly is a Firebase ID token and how is it different from a refresh token?
- An ID token is a short-lived JWT (~1 hour) that proves who the user is and is sent on each authenticated request. A refresh token is a long-lived credential the client SDK uses to silently obtain a new ID token when the current one expires. The ID token is what your backend verifies; the refresh token never leaves the client unless you explicitly send it.
- Do I need to verify Firebase tokens on my backend?
- Yes. Anything the client tells you about its identity can be forged. Use the Firebase Admin SDK's
verifyIdToken()on every privileged request. Client-side auth is only for UX — authorization decisions belong on the server. - How long does a Firebase ID token last?
- One hour. This is intentional — short-lived tokens limit the blast radius if a token is stolen. The Firebase client SDK refreshes silently using the refresh token, so users don't see logouts.
- Are Firebase security rules a substitute for backend auth checks?
- Security rules are backend auth for the path where clients talk directly to Firestore or Realtime Database — they execute on Google's servers, not in the browser. But if your own backend reads or writes through the Admin SDK, those calls bypass rules entirely. You need both layers, not one or the other.
- Can I migrate off Firebase Auth later?
- Yes, but plan for it. Firebase lets you export user records (including password hashes for most algorithms), so migrating to another provider is possible without forcing every user to reset their password. The harder part is migrating any custom claims and the call sites that rely on the Firebase SDK's specific APIs.
About the author — Kishan Vaghani
Kishan is the founder of ShareCode and writes about the engineering and security decisions that go into building production-grade SaaS products. ShareCode itself runs on Firebase Authentication, so every pattern in this article has been used (and a few mistakes earned) on a real codebase serving real users.
Final Thoughts
Firebase Authentication is much more than a login button. Under the hood, it is a managed identity service handling JWT issuance, refresh flows, OAuth integrations, session lifecycle, and the verification APIs your backend trusts. Treating it as a black box is the most common reason teams ship broken or insecure auth.
The patterns in this post — verify on the server, scope your OAuth requests, treat your rules file like production code, log everything, enforce MFA on admin accounts — are not optional. They are the difference between auth that works on day one and auth that still works at month twelve.
If you're building something that needs collaborative editing on top of Firebase, our writeup of how real-time sync works under the hood pairs naturally with this article — same stack, different layer.
Test these patterns in a live ShareCode editor
Spin up a code space and try the verifyIdToken snippet end-to-end with a teammate watching. No setup, no deploy step — just a URL you can share.