Auth Flow
Authentication spans both projects. The frontend runs NextAuth (sign-in UI, magic-link send, OAuth dance, session callback). The backend stores users + sessions + accounts + verification tokens, and validates Bearer-authed requests. They never share a database client: every adapter call goes over HTTP.
The bearer session token never reaches client JavaScript. NextAuth's httpOnly session cookie holds the token; server-side code reads it via next/headers and forwards it as Authorization: Bearer ... to the backend. Client components fetch via Server Actions or receive RSC-fetched props; they never see the bearer themselves.
The Two Sign-In Flows
Magic-link
1. User submits email on /signin
│
v
2. NextAuth Nodemailer provider runs on the frontend
│
├──► Adapter.createVerificationToken(...)
│ → POST /internal/auth-adapter/verification-tokens
│ (X-Internal-Token; backend stores it)
│
└──► sendVerificationRequest(...) (synchronous)
→ SMTP via Nodemailer (frontend env: EMAIL_SERVER_*)
│
v
3. User clicks the link in the email
│
v
4. NextAuth /api/auth/callback/nodemailer fires
│
├──► Adapter.useVerificationToken({ identifier, token })
│ → POST /internal/auth-adapter/verification-tokens/use
│ (atomic delete + return; backend ensures single use)
│
├──► Adapter.getUserByEmail(email)
│ → GET /internal/auth-adapter/users/by-email/:email
│ → if null: Adapter.createUser(...)
│
└──► Adapter.createSession({ sessionToken, userId, expires })
→ POST /internal/auth-adapter/sessions
│
v
5. NextAuth sets the session cookie (httpOnly, server-only)
OAuth (GitHub / Google)
1. User clicks "Continue with GitHub"
│
v
2. NextAuth redirects to GitHub
│
v
3. GitHub redirects back with auth code
│
v
4. NextAuth exchanges code for tokens, then:
├──► Adapter.getUserByAccount({ provider, providerAccountId })
│ → POST /internal/auth-adapter/users/by-account
│ → if not found:
│ ├── Adapter.createUser(...)
│ └── Adapter.linkAccount({ userId, provider, ... })
│
└──► Adapter.createSession(...)
│
v
5. NextAuth sets the session cookie
Both flows produce the same end state: a row in sessions with an opaque sessionToken, a row in users for the signed-in person, and (for OAuth) a row in accounts linking the provider identity to the user.
Frontend: NextAuth Setup
Adapter (frontend/lib/auth-adapter.ts)
BackendAdapter() implements all 14 NextAuth Adapter methods. Each one is a single internalApi() call to the matching /internal/auth-adapter/* endpoint. There's no DB driver in the frontend.
The full method list:
| Method | Endpoint |
|---|---|
createUser |
POST /internal/auth-adapter/users |
getUser |
GET /internal/auth-adapter/users/{id} |
getUserByEmail |
GET /internal/auth-adapter/users/by-email/{email} |
getUserByAccount |
POST /internal/auth-adapter/users/by-account |
updateUser |
PATCH /internal/auth-adapter/users/{id} |
deleteUser |
DELETE /internal/auth-adapter/users/{id} |
linkAccount |
POST /internal/auth-adapter/accounts |
unlinkAccount |
DELETE /internal/auth-adapter/accounts |
createSession |
POST /internal/auth-adapter/sessions |
getSessionAndUser |
GET /internal/auth-adapter/sessions/{token} |
updateSession |
PATCH /internal/auth-adapter/sessions/{token} |
deleteSession |
DELETE /internal/auth-adapter/sessions/{token} |
createVerificationToken |
POST /internal/auth-adapter/verification-tokens |
useVerificationToken |
POST /internal/auth-adapter/verification-tokens/use |
Wire-format note: dates serialize as ISO strings; the adapter wraps every backend response with a to* helper that hydrates them back to Date.
Edge config (frontend/auth.config.ts)
The lightweight slice that runs in the edge proxy (proxy.ts, Edge Runtime). It only declares OAuth providers and custom pages: no Node-only APIs, so the proxy bundle stays small.
Each OAuth provider is registered only when its credentials are present in the environment (FE-H8). Previously [GitHub, Google] were always registered, so even though the sign-in form hides the buttons, /api/auth/signin/github was still callable and produced a confusing OAuth error. Filtering by env means missing creds yield a clean 404 from NextAuth's provider router instead.
const providers: ProviderEntry[] = [];
if (process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET) {
providers.push(GitHub);
}
if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) {
providers.push(Google);
}
export default {
providers,
pages: { signIn: '/signin' },
} satisfies NextAuthConfig;
Full config (frontend/auth.ts)
The full NextAuth config: spreads auth.config.ts, attaches the BackendAdapter, registers the Nodemailer provider for magic-link sends, declares session.strategy = 'database', sets trustHost: true, and rate-limits magic-link issuance per email.
Magic-link rate limiting (DB-backed)
Rate limiting is DB-backed, not in-memory. The old per-replica Map<email, number[]> in frontend/auth.ts was removed (FE-H2/S5): it was effectively useless across multi-replica deploys and serverless cold starts (each replica counted independently), and was a slow OOM vector against a single replica. The limiter now lives on the backend, keyed by NFKC-normalized lowercase email in the magic_link_attempts table (migration 007, attempts TIMESTAMPTZ[]), so the count is shared across every replica. The decision logic is in backend/src/rate-limit/magic-link-rate-limit.service.ts; defaults are 3 attempts per 5 minutes (MAGIC_LINK_MAX_ATTEMPTS / MAGIC_LINK_WINDOW_MS).
There are two enforcement layers but one recording point, a peek-vs-record split:
- Peek (UX pre-check, records nothing). The sign-in Server Action (
app/signin/actions.ts) calls/internal/auth-adapter/magic-link/checkwithpeek: true. It re-reads the current state and simulates the verdict (including the hypothetical "this attempt would push us to N+1") without recording, so the form can surface "Try again in N seconds" verbatim: NextAuth otherwise swallows a throw fromsendVerificationRequestinto a genericEmailSignInError. - Record (the single recording point).
sendVerificationRequestinauth.tscalls the same endpoint with nopeekflag. This is the side-effect site (the only path that actually sends the email), so it is the only path that atomically counts the attempt. This closes the bypass where a client skips the form and POSTs/api/auth/signin/nodemailerdirectly: peek-only would never move the counter, leaving the direct path unlimited (see #11). It fails closed: a backend hiccup here refuses to send the email.
Session callback
The session callback sets only session.user.id and deliberately keeps the bearer session token off the client:
callbacks: {
async session({ session, user }) {
session.user.id = user.id;
// FE-C1: the bearer session token deliberately does NOT cross to
// the client. Server Components use `serverApi()` (which reads the
// token via `auth()` server-side) to call backend endpoints; client
// mutations go through Server Actions colocated as `actions.ts`.
// The httpOnly NextAuth cookie remains the only thing the browser
// sees. See frontend/CLAUDE.md "Server Components & Server Actions".
return session;
},
},
The bearer token never crosses to the client. BackendAdapter.getSessionAndUser deliberately does not patch sessionToken onto the AdapterUser (cite the FE-C1 comment in lib/auth-adapter.ts: "do NOT patch sessionToken onto the AdapterUser. The bearer token must never reach the session callback (and from there the client)"). Server-side callers read the token from the NextAuth httpOnly cookie via auth() inside serverApi() instead.
Session strategy
session: { strategy: 'database' }. Sessions live in the backend DB (not as JWTs). Each row carries sessionToken, userId, and expires. The browser only ever sees an opaque cookie value: same value as the stored token, but never decoded client-side.
This is what makes the backend able to validate sessions: a single lookup by sessionToken, no JWT secret to share.
Frontend: Calling the Backend
lib/api.ts exposes three openapi-fetch clients, each scoped to a different security boundary:
api: public, browser-safe. Use for unauthenticated reads.serverApi(): async, server-only. Reads the NextAuth httpOnly session cookie vianext/headersand attachesAuthorization: Bearer <token>. Throws if called from the browser.internalApi(): server-only. Used by the BackendAdapter; setsX-Internal-Token.
From a Server Component
// app/some/route/page.tsx
import { serverApi } from '@/lib/api';
export default async function Page() {
const client = await serverApi();
const { data, error } = await client.GET('/items');
if (error || !data) return null;
// render data ...
}
From a Server Action
// app/items/actions.ts
'use server';
import { serverApi } from '@/lib/api';
import { revalidatePath } from 'next/cache';
export async function createItem(formData: FormData) {
const client = await serverApi();
const { error } = await client.POST('/items', {
body: { name: String(formData.get('name')) },
});
if (error) throw new Error('Create failed');
revalidatePath('/items');
}
The bearer is read inside serverApi() from the httpOnly cookie: it never sits in any user-visible scope. XSS can't reach it; client JS can't read it; nothing serializes it into the HTML payload.
Client components consume the result either as RSC-fetched props or by submitting to a Server Action via <form action={actionFn}> plus React 19's useActionState. See the app/settings/AccountSettings.tsx + app/settings/actions.ts pair in the template for the canonical example.
For unauthenticated public reads (e.g. the public blog list), use the plain api client: no token, no await.
Backend: Validating the Token
AuthService.validateSession
async validateSession(sessionToken: string) {
if (!sessionToken) {
throw new UnauthorizedException('Session token is required');
}
const session = await this.db<SessionRow>('sessions')
.where({ session_token: sessionToken })
.first();
if (!session) throw new UnauthorizedException('Session not found');
if (new Date(session.expires) < new Date()) {
throw new UnauthorizedException('Session expired');
}
const user = await this.db<UserRow>('users')
.where({ id: session.user_id })
.first();
if (!user) throw new UnauthorizedException('User not found');
return { user, session };
}
Two queries (session lookup + user lookup) over Knex against Postgres, using snake_case columns (session_token, user_id).
Guards (backend/src/auth/)
The guards are REST CanActivate classes.
| Guard | Behavior |
|---|---|
SessionGuard |
Reads Authorization: Bearer <token>, calls AuthService.validateSession, attaches user to req. Throws 401 on any failure. |
AdminGuard |
Must be stacked after SessionGuard. Reads req.user.isAdmin and throws 403 if missing. |
InternalApiTokenGuard |
Reads X-Internal-Token, hashes both sides and compares with timingSafeEqual against process.env.INTERNAL_API_TOKEN. Applied to the entire InternalAuthAdapterController. |
Stack like this:
@UseGuards(SessionGuard, AdminGuard)
@Get()
async list() { /* req.user is the admin */ }
@CurrentUser() is a small param decorator that pulls req.user out of the execution context, usable on any route guarded by SessionGuard.
Internal API Surface
The /internal/auth-adapter/* controller is excluded from public Swagger UI in spirit (it lives in the spec but is locked behind InternalApiTokenGuard). Only the frontend's internalApi() ever calls these; the token is never sent to the browser, and the routes are typically deployed on a private network.
INTERNAL_API_TOKEN rotation:
- Generate a new token:
openssl rand -base64 32. - Set the new value on the backend(s).
- Set the same value on the frontend.
- Restart both. There's no overlap window: internal calls fail with 401 in the gap, which is short.
Security Notes
- Session tokens are opaque strings, generated by NextAuth. They aren't JWTs and can't be decoded.
isAdminis checked server-side byAdminGuard. The DTO does includeisAdminfor UI rendering, but the guard is the actual enforcement.AUTH_SECRETmust match between frontend and backend. NextAuth uses it for cookie signing; the backend uses it for nothing today (it's there for forward compatibility).- Expired sessions are rejected by
AuthService, and expired rows are swept on a schedule.session-cleanup.worker.tsships as a wired@Cron(CronExpression.EVERY_HOUR)job that runs hourly, deleting rows whoseexpiresis in the past fromsessions,verification_tokens, andmagic_link_attempts(see Architecture.md). The three DELETEs fire concurrently against the same pool. - Magic-link sentinel admin: the seeded MCP admin user uses email
mcp-admin@invalid.crabstack.local. The.invalidsuffix is RFC 6761 reserved (no MX possible) and the auth-adapter short-circuits magic-link issuance for it. Don't rename it without re-reading audit decision S3.
Related
- Architecture - System overview and module structure
- Adding a Module - How to add guards to new routes
- Environment Variables - Auth-related env vars (
AUTH_SECRET,INTERNAL_API_TOKEN, OAuth, SMTP)