Skip to content
Synexa
How It Works

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

  1. 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.

  2. Exchange — the client POSTs that ID token to /api/auth/session (an API route handler). The request is origin-checked for CSRF.

  3. Verify — the server verifies the token with the Firebase Admin SDK (verifyIdToken(token, /* checkRevoked */ true)).

  4. Cookie — the server mints a session cookie with createSessionCookie(...) and sets __session as HttpOnly, Secure (in production), SameSite=Lax.

  5. Sign outDELETE /api/auth/session clears the cookie and calls revokeRefreshTokens(uid). Because every reader verifies with checkRevoked: 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() in AuthContext treats a failed DELETE as a failure and throws rather than clearing local state. __session is HttpOnly, 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

FileRole
lib/firebase-admin.tsAdmin SDK initialization (server)
lib/firebase.tsClient SDK initialization (browser)
contexts/AuthContext.tsxClient auth state + sign-in/out methods
app/api/auth/session/route.tsPOST (create) / DELETE (destroy) the session cookie
app/api/auth/send-otp/route.tsGenerates & emails the email login code
app/api/auth/verify-otp/route.tsVerifies the code, returns a custom token
lib/auth-helpers.tsServer helpers: requireActiveSession(), requireActiveAdminSession(), getUserId()
lib/require-active-user.tsrequireActiveApplicationUser() for cost-bearing API routes
lib/api-auth.tsverifyFirebaseUser() / resolveAuthCredential() for route handlers
lib/admin-auth.tsverifyAdmin() for admin API routes
proxy.tsEdge route guard (the newer Next.js replacement for middleware.ts)

Protecting Routes

There are two layers:

  1. Edge guardproxy.ts redirects unauthenticated requests for /dashboard, /settings, /profile, /archive, and /admin to /login. It's a cheap presence check on the __session cookie; full verification happens server-side.
  2. 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 doesUse
Spends money / consumes quotarequireActiveApplicationUser(req)
Reads or writes the caller's own dataverifyFirebaseUser(req) + your own lifecycle check
Mutating route that accepts cookie or BearervalidateCookieAuthenticatedRequestOrigin(req) before the auth call
Admin onlyverifyAdmin(request) / guardAdminMutation(request)

Every authenticated route accepts a session cookie or an Authorization: Bearer <idToken>. Both are verified with checkRevoked = 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 __session cookie is present and exempts a pure-Bearer client (which cannot be CSRF'd). Do not key that exemption on the presence of an Authorization header: verifyFirebaseUser resolves 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() and withAuth() 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 with userId: "anonymous:<ip>", so if (!result.success) return 401 shipped an open endpoint, and using context.userId as 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 in lib/rate-limit.ts explicitly.

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:

  1. User enters their email → POST /api/auth/send-otp.
  2. 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.
  3. 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 === true and not deactivated; deletion_failed may sign in for recovery), then returns a Firebase custom token.

The per-IP limits above depend on TRUSTED_PROXY being set correctly. When the real client IP cannot be determined, IP-based limiting is disabled and a SECURITY WARNING is logged — see SECURITY.md. Per-email limits (the 60s resend floor, the 5-attempt counter) always apply.

  1. 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 (emailSchema in lib/validators.ts), so User@Gmail.com and user@gmail.com are the same account.
  • verify-otp resolves 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 role only; 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:

ConditionSet byChecked
APP_E2E_SEAM === "enabled"next.config.mjs env: block, from E2E_SEAM_BUILDinlined into the bundle at build time
E2E_TEST_MODE === "true"the runtime environmentat 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 the next.config.env indirection.

Rules if you touch this.

  • Deploy artifacts must come from npm run build. npm run build:e2e produces a backdoored bundle for CI only — it prints a warning when it runs, and a production server started from one logs a SECURITY: error on boot (lib/env.ts).
  • Keep the gate build-time. If you need the seam somewhere new, import isE2ETestMode() from lib/e2e-test-mode.ts — never re-implement the check.
  • __tests__/release/repo-guards.test.ts fails the build if the flag appears in a deployment config, if any script sets it besides the dedicated wrapper, or if the build script starts producing seam artifacts.

Next: Read about Payments.