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.
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: checkAuth(), getUserId(), withAuth() |
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 { getUserId } from "@/lib/auth-helpers";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const userId = await getUserId(); // verifies the session cookie; null if not signed in
if (!userId) redirect("/login");
// ...render for `userId`
}
For API routes that need the full context (and rate limiting), use checkAuth(), which returns { success, context: { userId, ip } } or an error with a status code. Admin routes use verifyAdmin(request).
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). - 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), then returns a Firebase custom token. - 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.
Next: Read about Payments.