Authentication
This kit uses Firebase Authentication for identity, with server-side session cookies for stateful, secure sessions. Passwordless email (OTP) and Google sign-in are both wired up.
The Auth Flow
-
Sign in (client) — the user signs in with an email one-time code or Google. Either way the Firebase client SDK produces a Firebase ID token.
-
Exchange — the client
POSTs that ID token to/api/auth/session(an API route handler). The request is origin-checked for CSRF. -
Verify — the server verifies the token with the Firebase Admin SDK (
verifyIdToken(token, /* checkRevoked */ true)). -
Cookie — the server mints a session cookie with
createSessionCookie(...)and sets__sessionasHttpOnly,Secure(in production),SameSite=Lax. -
Sign out —
DELETE /api/auth/sessionclears the cookie and callsrevokeRefreshTokens(uid). Because every reader verifies withcheckRevoked: true, this ends the user's sessions on all devices, not just the current one. That is deliberate — it is what makes "sign out" actually mean signed out — but it is a product decision worth knowing: signing out on a phone also ends the desktop session on its next server render.signOut()inAuthContexttreats a failedDELETEas a failure and throws rather than clearing local state.__sessionisHttpOnly, so nothing in the browser can remove it; signing out locally anyway would show a signed-out UI over a still-valid session (up to 14 days) — a real hazard on a shared device.
Key Files
| File | Role |
|---|---|
lib/firebase-admin.ts | Admin SDK initialization (server) |
lib/firebase.ts | Client SDK initialization (browser) |
contexts/AuthContext.tsx | Client auth state + sign-in/out methods |
app/api/auth/session/route.ts | POST (create) / DELETE (destroy) the session cookie |
app/api/auth/send-otp/route.ts | Generates & emails the email login code |
app/api/auth/verify-otp/route.ts | Verifies the code, returns a custom token |
lib/auth-helpers.ts | Server helpers: requireActiveSession(), requireActiveAdminSession(), getUserId() |
lib/require-active-user.ts | requireActiveApplicationUser() for cost-bearing API routes |
lib/api-auth.ts | verifyFirebaseUser() / resolveAuthCredential() for route handlers |
lib/admin-auth.ts | verifyAdmin() for admin API routes |
proxy.ts | Edge route guard (the newer Next.js replacement for middleware.ts) |
Protecting Routes
There are two layers:
- Edge guard —
proxy.tsredirects unauthenticated requests for/dashboard,/settings,/profile,/archive, and/adminto/login. It's a cheap presence check on the__sessioncookie; full verification happens server-side. - Server-side — in a Server Component or route, resolve the user with
lib/auth-helpers:
import { requireActiveSession } from "@/lib/auth-helpers";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
// Verifies the session cookie AND that the account is usable: isActive === true,
// not mid-deletion, not deactivated.
const session = await requireActiveSession();
if (!session) redirect("/login");
// ...render for `session.uid`
}
Use requireActiveSession() (or requireActiveAdminSession() for admin pages) —
not getUserId(). getUserId() verifies the session cookie and nothing else:
it does not check isActive, deactivationStatus, or deletionStatus, so a
deactivated or mid-deletion user (who legitimately still holds a recovery session)
would load the page. It exists for the narrow case where you only need the uid and
have already established the account is usable.
For API routes, pick the guard by what the route does:
| Route does | Use |
|---|---|
| Spends money / consumes quota | requireActiveApplicationUser(req) |
| Reads or writes the caller's own data | verifyFirebaseUser(req) + your own lifecycle check |
| Mutating route that accepts cookie or Bearer | validateCookieAuthenticatedRequestOrigin(req) before the auth call |
| Admin only | verifyAdmin(request) / guardAdminMutation(request) |
Every authenticated route accepts a session cookie or an
Authorization: Bearer <idToken>. Both are verified withcheckRevoked = true. If both are present and name different users, the request is refused rather than letting the cookie silently win.For mutating routes, call
validateCookieAuthenticatedRequestOrigin(req)first. It enforces the origin check when a__sessioncookie is present and exempts a pure-Bearer client (which cannot be CSRF'd). Do not key that exemption on the presence of anAuthorizationheader:verifyFirebaseUserresolves the cookie first, so a cross-site request carrying the victim's cookie plus a junk Bearer would authenticate as the victim and skip the check.
checkAuth()andwithAuth()were removed in v1.1.1. They were IP-keyed rate-limiting helpers shaped like authorization guards:checkAuth()returned{ success: true }for an unauthenticated caller withuserId: "anonymous:<ip>", soif (!result.success) return 401shipped an open endpoint, and usingcontext.userIdas an owner key keyed data on a value any client can reproduce. Nothing in the kit used them. Use the guards in the table above and call the limiters inlib/rate-limit.tsexplicitly.
Signing out ends every session, on every device
DELETE /api/auth/session clears the cookie and calls
revokeRefreshTokens(uid). Because every guard verifies with checkRevoked = true,
that invalidates the user's session cookies and ID tokens everywhere — the
phone is signed out when the laptop signs out.
This is deliberate (a shared or lost device is the case that matters), but it is a
product decision, not a technicality: if you want per-device sign-out, drop the
revokeRefreshTokens call and expose "Sign out everywhere" as its own control.
Email OTP (passwordless)
This is a custom one-time-code flow — it does not use Firebase's built-in email link:
- User enters their email →
POST /api/auth/send-otp. - The server generates a 6-digit code, stores a salted SHA-256 hash of it in Firestore (
email_login_otps/{email}), and emails the code via Resend. Sends are rate-limited (10/hour/IP) and origin-checked like every other mutating route. - User enters the code →
POST /api/auth/verify-otp. The server checks the hash, expiry, and attempt count inside a transaction (verifies are rate-limited 10/min/IP), confirms the account is usable (isActive === trueand not deactivated;deletion_failedmay sign in for recovery), then returns a Firebase custom token.
The per-IP limits above depend on
TRUSTED_PROXYbeing set correctly. When the real client IP cannot be determined, IP-based limiting is disabled and aSECURITY WARNINGis logged — see SECURITY.md. Per-email limits (the 60s resend floor, the 5-attempt counter) always apply.
- The client exchanges the custom token for an ID token and runs the session-cookie exchange above.
Codes are never stored in plaintext and are never readable by clients (see firestore.rules).
Sign-in OTP requests intentionally use the same public status/body and the same delivery path whether or not an application account exists. After mailbox ownership is proven, an address without an account receives only the generic Invalid or expired code response. This prevents the anonymous OTP APIs from being used for account enumeration. Sign-up remains available as an explicit action and requires accepting the Terms.
Expired email_login_otps and Firestore-backed auth_rate_limits records are deleted in bounded, idempotent batches by the authenticated scheduled recovery route. No client can read or write either collection.
Google Sign-In
Handled by the Firebase client SDK. After the popup completes, the resulting ID token is exchanged for a session cookie via the same /api/auth/session flow.
One Account Per Email (required setting)
The kit is designed to treat one email address as one account, regardless of sign-in method. With Firebase's required account-linking setting, a user who signs up with Google and later uses the email code (or vice versa) resolves to the same account. This works because:
- Emails are normalized to lowercase everywhere (
emailSchemainlib/validators.ts), soUser@Gmail.comanduser@gmail.comare the same account. verify-otpresolves an existing Firebase user by email (getUserByEmail) instead of blindly creating a second identity. Firebase's one-account-per-email policy then resolves trusted providers to one Firebase account.
⚠️ Required Firebase Console setting. In Authentication → Settings → User account linking, keep "Link accounts that use the same email" (this is Firebase's default). If you switch to "Create multiple accounts for each identity provider", Firebase will mint a separate account for Google vs. email on the same address, fragmenting one person into two app accounts.
As defense-in-depth against a misconfigured project or a provider-linking edge
case, the app also enforces uniqueness at the application layer:
ensureUserDocument (lib/user-helpers.ts) refuses to
create a second users/{uid} profile for an email another account already owns,
and the auth routes return 409 (email already registered) rather than
silently fragmenting the application profile. That refusal is safe, but it is
not a substitute for the required Firebase Console setting.
Admin Access
Admin is a Firestore field — role: "admin" on users/{uid} — checked on both the server (lib/admin-auth.ts) and the client (contexts/AuthContext.tsx). It is not an environment flag on its own, and ADMIN_EMAIL (the contact-form recipient) grants no privileges.
Bootstrap your first admin — set SUPER_ADMIN_EMAILS (recommended). A comma-separated allowlist of emails that are auto-granted role: "admin" on sign-in:
SUPER_ADMIN_EMAILS=you@your-domain.com,cofounder@your-domain.com
Set it in Vercel (or .env.local), then sign in — ensureUserDocument creates a listed email as an admin, or promotes an existing non-admin account with that email on its next sign-in. The grant only applies to a provider-verified email (OTP always proves ownership; Google must report email_verified), so a spoofable or unverified email claim can't earn admin. The grant is parsed case- and whitespace-insensitively and each promotion is recorded in admin_audit_logs (action: "super_admin_auto_grant").
- Grant-only. The list only ever promotes. Removing an email later does not demote that account — demote via the admin dashboard instead. This also means the env var is a safe lockout-recovery path: add your email, sign in, you're an admin again. It can never trip the last-admin guard, since it only adds admins.
- Deactivated accounts. The grant sets
roleonly; a deactivated user (isActive: false) still won't be a usable admin until reactivated.
Alternative — the local script. Without the env var, edit EMAIL_TO_MAKE_ADMIN in scripts/make-admin.ts and run npx tsx scripts/make-admin.ts (the user must have signed up first).
Once you have one admin, promote/demote everyone else from the Admin Dashboard (/admin → shield button → /api/admin/update-user), which is admin-guarded with last-admin protection.
Activation and deactivation changes span Firebase Auth and Firestore, so they are executed through durable admin_state_operations. Each operation records Auth, token-revocation, Firestore, and audit completion, and fences every state write with its generation and worker lease. /api/cron/stripe-reconcile selects only due claimableAt operations after billing recovery and resumes them after a partial provider failure. Access remains fail-closed until all steps converge.
Session Expiry
Sessions last 14 days (SESSION_MAX_AGE in app/api/auth/session/route.ts). Change that constant to adjust the lifetime.
Deterministic OTP test seam
The kit contains exactly one intentional authentication bypass, used so the Playwright suite can drive real sign-up and sign-in journeys without a live email inbox. It is worth understanding before you change anything around it.
What it does. When active, /api/auth/send-otp returns a fixed code instead of
emailing one, and /api/auth/verify-otp accepts that code for any address
(E2E_OTP_CODE, default 111111).
How it is gated. Two conditions, and the first is decided at build time:
| Condition | Set by | Checked |
|---|---|---|
APP_E2E_SEAM === "enabled" | next.config.mjs env: block, from E2E_SEAM_BUILD | inlined into the bundle at build time |
E2E_TEST_MODE === "true" | the runtime environment | at request time |
Because next.config.env values are textually inlined, npm run build
compiles the branch to a constant false and removes it. Setting E2E_TEST_MODE,
E2E_SEAM_BUILD, or APP_E2E_SEAM on a normally-built running server does
nothing — verified by starting a plain build with all three set and confirming the
endpoint still refuses to issue a deterministic code.
A
NEXT_PUBLIC_*variable would not be sufficient here. Those are inlined for the client bundle but read at runtime on the server, so gating on one would let an operator enable a full authentication bypass with an environment variable. That distinction is the reason for thenext.config.envindirection.
Rules if you touch this.
- Deploy artifacts must come from
npm run build.npm run build:e2eproduces a backdoored bundle for CI only — it prints a warning when it runs, and a production server started from one logs aSECURITY:error on boot (lib/env.ts). - Keep the gate build-time. If you need the seam somewhere new, import
isE2ETestMode()fromlib/e2e-test-mode.ts— never re-implement the check. __tests__/release/repo-guards.test.tsfails the build if the flag appears in a deployment config, if any script sets it besides the dedicated wrapper, or if thebuildscript starts producing seam artifacts.
Next: Read about Payments.