Authentication Flow

Complete auth architecture from user input to database. Every request, cookie, and security layer documented.

Better Auth Fastify Mercurius Next.js PostgreSQL Prisma
01 Frontend User Input
User types email, password, and confirm password in the sign-up form.
02 Frontend Client Validation
Yup schema validates before any network request: email format, password min 10 chars, confirmPassword must match.
Spam protection: 2s cooldown between submissions + ref lock to prevent rapid-fire.
03 Frontend Better Auth Client
useHandleSignUp hook calls signUp.email() from Better Auth client SDK.
Method
POST
Endpoint
/api/auth/sign-up/email
Content-Type
application/json
Body
{ email, password, name: email }
04 Security CORS + Rate Limit
CORS (dual check): First, @fastify/cors validates the Origin header at the HTTP level (localhost:3000/4000/5004 in dev, ALLOWED_ORIGINS in prod). Then Better Auth performs its own origin check against the trustedOrigins list.
Rate limit: 100 req/min in production, 1000 in dev. Exceeding returns 429.
Helmet: Security headers applied (XSS protection, clickjacking prevention, CSP in production).
05 Backend Fastify Route Handler
fastify.all("/api/auth/*") catches the request. Converts Fastify request to Fetch API Request format (required by Better Auth), then passes to auth.handler(req).
06 Backend Better Auth Processing
Server-side validation: email format, password min 10 / max 128 chars, checks if user already exists. If valid: hashes password with scrypt (Better Auth default).
User exists? Returns error code USER_ALREADY_EXISTS. Frontend maps it via useAuthErrorMessage hook to i18n translated text.
07 Database Records Created
Prisma adapter inserts 3 records into PostgreSQL. Because autoSignIn: true is set in the Better Auth config, a session is created immediately during sign-up — the user is logged in right away without needing a separate sign-in step.
INSERT INTO "user" (id, name, email, emailVerified) INSERT INTO "account" (id, userId, providerId: "credential", password: <scrypt hash>) INSERT INTO "session" (id, userId, token, expiresAt, ipAddress, userAgent) -- autoSignIn: true in auth config -- session created immediately at sign-up
08 Backend Response + Set-Cookie
Better Auth sends the response back through Fastify with a session cookie.
200 OK Set-Cookie: better-auth.session_token=TOKEN; HttpOnly; SameSite=Lax; Secure (in production); Max-Age=604800 (7 days) Body: { user: { id, email, name }, session: { token } }
09 Frontend Success Redirect
onSuccess callback fires. Browser stores the HttpOnly cookie automatically. router.push("/") redirects user to the dashboard.
Cookie is HttpOnly — JavaScript cannot read or modify the session token. Protection against XSS token theft.
01 Frontend User Input
User types email and password. Optional rememberMe checkbox — the value is passed to signIn.email({ rememberMe }). Better Auth handles it internally: when true, the session cookie is set as persistent (survives browser restarts); when false, it's a session cookie that expires when the browser closes.
02 Frontend Validation + Auth Client
Yup validates email format + password min 10 chars.
Spam protection active. useHandleLogin hook calls signIn.email().
Method
POST
Endpoint
/api/auth/sign-in/email
Body
{ email, password, rememberMe }
03 Security CORS + Rate Limit
Same dual CORS check as sign-up (@fastify/cors + Better Auth trustedOrigins), rate limiting, and Helmet security headers applied before auth logic runs.
04 Backend Credential Verification
Better Auth processes sign-in:
1. Look up user by email in "user" table 2. Retrieve "account" record (providerId: "credential") 3. Compare submitted password against scrypt hash
Both "user not found" and "wrong password" return the same error code: INVALID_EMAIL_OR_PASSWORD. This prevents user enumeration attacks — attacker cannot determine if an email is registered.
05 Database Session Created
Password matches. New session record inserted:
INSERT INTO "session" (id, userId, token, expiresAt, ipAddress, userAgent)
06 Backend Response + Set-Cookie
200 OK Set-Cookie: better-auth.session_token=TOKEN; HttpOnly; SameSite=Lax; Secure (in production); Max-Age=604800 Body: { user: {...}, session: { token, expiresAt } }
07 Frontend Success Redirect
Cookie stored by browser. router.push("/") redirects to dashboard. Errors mapped via useAuthErrorMessage hook with i18n support.
01 Browser Page Navigation
User navigates to a protected page (e.g. /orders). Browser automatically attaches the session cookie.
Method
GET
URL
/en/orders
Cookie
better-auth.session_token=TOKEN
02 Middleware proxy.ts — Route Protection
Next.js middleware runs at the edge. Checks if auth is configured (env vars present), then calls getSessionCookie(request).
Middleware only checks cookie presence — it does NOT validate the token. It's a fast redirect guard. Real validation happens on the backend.
03 SSR Protected Layout — Server Session Check
Second layer of protection. The (protected)/layout.tsx server component calls getSession() from auth-server.ts, which fetches NEXT_PUBLIC_AUTH_URL/get-session with forwarded cookies (cache: "no-store"). If no valid session is returned, it calls redirect("/{locale}/login").
const session = await getSession() // fetch to /api/auth/get-session with forwarded cookies if (!session) redirect(`/${locale}/login`)
Unlike middleware (cookie presence only), this performs a full server-side session validation against the backend. Both layers must pass for a protected page to render.
04 SSR Server Component — getData()
Server component calls getData("orders"). It reads cookies from the incoming request with headers().get("cookie") and forwards them to Apollo Client.
const cookie = headersList.get("cookie") client.query({ query, context: { headers: { cookie } }, fetchPolicy: "no-cache" })
05 SSR GraphQL Request
Apollo Client (server-side, running in the Next.js SSR layer) sends the GraphQL query to the Fastify backend with forwarded cookies. The credentials: "include" setting in Apollo Client config ensures cookies are attached to cross-origin requests.
Method
POST
Endpoint
/graphql
Cookie
better-auth.session_token=TOKEN
Body
{ query: "{ orders { id status total ... } }" }
06 Security CORS + Rate Limit
Origin validated, rate limit checked. Helmet applies security headers (XSS protection, clickjacking prevention).
07 Mercurius Session Verification
Mercurius context function runs on every request. Calls auth.api.getSession() to validate the token.
auth.api.getSession({ headers: fromNodeHeaders(req.headers) }) // cookieCache: 5 min -- skips DB lookup for repeated requests // Returns { user, session } or null
08 Security preExecution Hook
The GraphQL preExecution hook checks the session from context. If no valid session: throws ErrorWithProps with status 401. This blocks resolver execution entirely — no data is returned.
if (!context.session) { throw new mercurius.ErrorWithProps("Not authenticated", { code: "UNAUTHENTICATED" }, 401) } // Result: { data: null, errors: [{ message: "Not authenticated" }] }
Critical: using throw (not return). Return would add errors but still execute resolvers, leaking data to unauthenticated users.
09 Database Data Query
Session valid. Resolver executes, Prisma queries PostgreSQL:
SELECT * FROM "order" ORDER BY ...
10 Backend GraphQL Response
Mercurius returns data following GraphQL spec (always HTTP 200). In production, errorFormatter sanitizes error details.
200 OK (GraphQL spec -- always 200) { "data": { "orders": [...] } }
11 SSR Rendered HTML
Server component receives data from Apollo Client, renders the page to HTML, and sends it to the browser. User sees the fully loaded dashboard.
01 Frontend User Initiates Logout
User opens the User Menu dropdown in the navbar, expands the Auth section, and clicks Sign Out. This triggers showLogoutModal() from the useNavbarModals hook.
02 Frontend Confirmation Modal
LogoutModal component renders a dialog with title, description, and two buttons: Cancel and Confirm. Supports returnFocusRef for accessibility — focus returns to the trigger button after closing.
03 Frontend Better Auth signOut()
User confirms. useHandleLogout hook sets isLoggingOut state in layoutStore and calls signOut() from Better Auth client.
Method
POST
Endpoint
/api/auth/sign-out
Cookie
better-auth.session_token=TOKEN
04 Security CORS + Rate Limit
Same dual CORS check as other auth endpoints (@fastify/cors + Better Auth trustedOrigins), rate limiting, and Helmet security headers applied before processing.
05 Backend Session Invalidation
Better Auth auth.handler(req) processes the sign-out request. Identifies the session from the cookie token and invalidates it.
06 Database Session Deleted
Prisma adapter deletes the session record from PostgreSQL:
DELETE FROM "session" WHERE token = 'TOKEN'
07 Backend Cookie Cleared
Response instructs the browser to delete the session cookie by setting it to expire immediately.
200 OK Set-Cookie: better-auth.session_token=(empty); HttpOnly; SameSite=Lax; Secure (in production); Max-Age=0
08 Frontend Hard Reload
On success, window.location.reload() performs a full page reload. This clears all client-side state (Zustand stores, React state, Apollo cache). The reload triggers middleware which detects no session cookie and redirects to /login.
A hard reload (not router.push) is intentional — it guarantees all cached session data is wiped. Soft navigation could leave stale auth state in memory.

Every request passes through multiple security layers. The table below maps each protection to its location in the stack and what it defends against.

Layer Protection Location
Yup Validation Email format, password min length Frontend
Submit Cooldown 2s lock + ref guard against rapid-fire spam Frontend
Next.js Middleware Checks cookie presence, redirects unauthenticated users to /login Frontend SSR
CORS Blocks requests from unknown origins Backend
Rate Limiting 100 req/min in production (prevents brute force) Backend
Helmet Security headers: XSS protection, clickjacking prevention, CSP in prod Backend
Better Auth Validation Email format, password min 10 / max 128, duplicate check Backend
Password Hashing scrypt (Better Auth default) — plaintext never stored Backend
HttpOnly Cookie JavaScript cannot access session token (XSS protection) Cookie
CSRF Protection Multi-layered: SameSite=Lax cookies, Origin/Referer header validation, and Fetch Metadata (Sec-Fetch-Site/Mode/Dest) checks — all handled automatically by Better Auth Cookie + Backend
Secure Flag Set automatically by Better Auth in production — cookie is only sent over HTTPS, preventing token interception on unencrypted connections Cookie
credentials: "include" Apollo Client config — ensures session cookies are attached to GraphQL requests sent from the SSR layer to the backend Frontend SSR
preExecution Hook Throws if no valid session — blocks resolver execution entirely Backend GQL
errorFormatter Sanitizes error details in production (no stack traces leaked) Backend GQL
Query Depth Limit Max 12 levels of nesting (prevents DoS via deep queries) Backend GQL
cookieCache 5 min session cache — performance vs. instant revocation tradeoff Backend
Frontend CSP Content-Security-Policy in next.config.js — restricts script/connect/frame sources. Dynamic connect-src includes GRAPHQL_URL and AUTH_URL origins Frontend
X-Frame-Options Set to DENY — prevents the app from being embedded in iframes on other domains (clickjacking protection) Frontend
X-Content-Type-Options Set to nosniff — prevents browsers from MIME-type sniffing, blocking attacks that serve executable content disguised as other file types Frontend
HSTS Strict-Transport-Security with max-age 2 years, includeSubDomains, preload — forces HTTPS, prevents SSL stripping and downgrade attacks Frontend
Permissions-Policy Disables camera, microphone, and geolocation APIs — reduces attack surface by blocking access to unnecessary browser capabilities Frontend
X-XSS-Protection Set to 0 (disabled) as recommended by OWASP — legacy browser XSS auditor was removed from modern browsers and could itself introduce vulnerabilities Frontend
Referrer-Policy strict-origin-when-cross-origin — sends full URL for same-origin requests, only origin for cross-origin, nothing on HTTPS→HTTP downgrade Frontend
Cache-Control no-store, no-cache on all /api/auth/* responses — prevents proxies and browsers from caching session tokens or auth data Backend
Env Validation Fail-fast on startup — validateEnv() throws if DATABASE_URL or BETTER_AUTH_SECRET missing Backend
Protected Layout Server-side getSession() fetch in (protected)/layout.tsx — validates session against backend, not just cookie presence Frontend SSR
Error Code Mapping ~40 Better Auth error codes grouped into i18n keys via useAuthErrorMessage. Multiple codes map to the same message (e.g. USER_NOT_FOUND, CREDENTIAL_ACCOUNT_NOT_FOUND → INVALID_EMAIL_OR_PASSWORD) to prevent user enumeration Frontend i18n

05Scope

Some authentication and security features are intentionally excluded to keep the starter lightweight and free from external service dependencies. This may change as the project evolves.

Feature Status Note
MFA / 2FA Not included Adds complex UI flow (QR setup, recovery codes, TOTP input) — use Better Auth twoFactor plugin to add
Account Lockout Not included Rate limiting on auth endpoints covers brute-force protection
Idle Session Timeout Not included Requires client-side activity tracking — only absolute expiry is used
Email Verification Not included Requires email provider (SendGrid, AWS SES) and verification UI
Password Reset UI only Requires email provider for sending reset links — same dependency as email verification
Password Strength Meter Not included Heavy dependency (~400KB) — min-length rule used instead

DBSession Configuration

Parameter Value Description
expiresIn 7 days (604800s) Session lifetime
updateAge 1 day (86400s) Token refresh interval
cookieCache 5 min (300s) Better Auth stores encoded session data directly in the cookie as a cache. When getSession() is called, it first checks this cached payload — if still within the 5 min window, it returns the result without querying the database. After expiry, the next getSession() call hits the DB and refreshes the cache

DBDatabase Tables

Table Key Columns
user id, name, email, emailVerified, image, createdAt, updatedAt
session id, token (unique), userId (FK → user, ON DELETE CASCADE), expiresAt, ipAddress, userAgent, createdAt, updatedAt
account id, accountId, userId (FK → user, ON DELETE CASCADE), providerId, password (scrypt hash), createdAt, updatedAt
verification id, identifier, value, expiresAt