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.
Signing Out
1. User submits the sign-out form (landing nav, /settings,
or the backoffice sidebar)
│
v
2. signOutAction() in app/components/actions.ts
│
├──► DELETE /internal/auth-adapter/sessions/:token
│ (explicit, and throws if it fails; the row is what
│ the backend validates bearers against)
│
└──► signOut({ redirectTo: '/' })
→ clears the session cookie
→ asks the adapter to delete the row a second time,
which answers 200 `success: false` and is a no-op
│
v
3. Redirect to /
Why the delete is explicit rather than left to signOut(). @auth/core catches an adapter failure inside signOut(), logs it, and clears the cookie anyway (node_modules/@auth/core/lib/actions/signout.js). Left alone that reports success while the session row survives for its full lifetime, and the user's cookie is now gone, so they can never trigger the delete again: an unrevokable live token. Server-side revocation is the whole reason to run database sessions instead of JWTs, so sign-out fails closed instead. A failed delete throws, the cookie and the row both stay, and the user can retry.
SignOutButton (app/components/SignOutButton.tsx) is the one control, reused by all three surfaces with a caller-supplied className. It is a form post, not a link: sign-out mutates state, so it must not be reachable by a cross-site GET. Nothing about it is client-side, so it still works with JavaScript disabled.
deleteAccountAction calls signOut() directly, without the explicit delete above. Account deletion is queued rather than immediate (POST /users/me/delete-account returns { queued: true }), and the worker clears that user's sessions when it runs.
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. Registering one unconditionally would leave /api/auth/signin/github callable even though the sign-in form hides the button, answering with a confusing OAuth error rather than a miss. Filtering by env yields a clean 404 from NextAuth's provider router instead.
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. A per-replica Map<email, number[]> cannot work here: each replica would count independently, so the effective limit multiplies by the replica count and resets on every serverless cold start, and an unbounded map is a slow OOM vector against a single replica. The limiter lives on the backend instead, 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. It fails closed: a backend hiccup here refuses to send the email.
Session callback
The session callback sets session.user.id and session.user.isAdmin, then
rebuilds the session from an allowlist before returning it:
callbacks: {
async session({ session, user }) {
session.user.id = user.id;
// Rendering hint only, read by the landing nav to show a Backoffice
// link (app/page.tsx). AdminGuard on the backend is what actually
// enforces access. `user` is the users row the adapter re-reads on
// every session lookup, so a revoked admin loses the link on their
// next page render, and the guard blocks them immediately either way.
session.user.isAdmin = user.isAdmin ?? false;
// Under the database strategy this callback receives the adapter's
// session ROW, and whatever it returns is the JSON body of
// `/api/auth/session`. Returning the row as-is publishes a working
// bearer token to client JavaScript. See lib/public-session.ts.
return toPublicSession(session);
},
},
Return the row and you ship an account-takeover. /api/auth/session is a
public endpoint that any script on the page can fetch, so whatever this
callback returns is readable by anything running in the browser. The row carries
sessionToken, and the backend accepts that value as
Authorization: Bearer, so a return session; here hands an injected script
a credential that can do anything the user can, up to and including
POST /users/me/delete-account. The httpOnly cookie stops being a boundary.
toPublicSession (lib/public-session.ts) is an allowlist, not a delete of
the two known columns: a denylist fails open, so a column added to the
sessions row later would publish itself. If you add a field clients need,
add it there deliberately. user is rebuilt field by field for the same
reason - isAdmin is the one deliberate addition on top of id, name,
email and image, added so the landing nav can gate a Backoffice link
on it without a per-request admin probe on the highest-traffic public page.
It is a rendering hint, not the access decision: AdminGuard and the
proxy.ts / app/backoffice/layout.tsx probes are untouched and remain
what actually blocks a non-admin from /backoffice.
This is one of two places the token is kept off the client, and both are
load-bearing. BackendAdapter.getSessionAndUser also does not patch
sessionToken onto the AdapterUser (see the comment on
getSessionAndUser in lib/auth-adapter.ts). Server-side callers read the token from the NextAuth
httpOnly cookie via next/headers inside serverApi() instead.
Session strategy
session: { strategy: 'database' }. Sessions live in the backend DB, not as JWTs. Each row carries sessionToken, userId and expires, and the browser sees that value only as an httpOnly cookie its own scripts cannot read. Keeping it out of /api/auth/session is what the session callback above is for.
Frontend: Calling the Backend
lib/api.ts exposes three openapi-fetch clients, each scoped to a different security boundary:
api: public, browser-safe. Unauthenticated reads, noawait.serverApi(): async, server-only. Reads the httpOnly session cookie vianext/headersand attachesAuthorization: Bearer <token>. Throws if called from the browser.internalApi(): server-only. Used by the BackendAdapter; setsX-Internal-Token.
Because the bearer is read inside serverApi(), it never sits in any user-visible scope: XSS can't reach it, client JS can't read it, and nothing serializes it into the HTML payload. Client components consume results as RSC-fetched props or submit to a Server Action via <form action={actionFn}> plus React 19's useActionState. The app/settings/ page is the canonical example, and Adding a Module walks the pattern end to end.
Backend: Validating the Token
AuthService.validateSession
Two Knex queries: look the row up by session_token, then load its user by user_id. A missing token, a missing row, an expires in the past, or a missing user each throw UnauthorizedException. No JWT secret is involved, which is what lets the backend validate a session NextAuth issued.
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.is_admin 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 controller is @ApiExcludeController(), so these routes are absent from openapi.json and Swagger UI entirely; the frontend types them by hand in lib/internal-api-types.ts. Only internalApi() ever calls them, the token is never sent to the browser, and the routes are typically deployed on a private network.
Rotating INTERNAL_API_TOKEN: see Environment Variables.
Security Notes
- Session tokens are opaque strings, generated by NextAuth. They aren't JWTs and can't be decoded.
isAdminis checked server-side byAdminGuard, which readsreq.user.is_adminoff the rowSessionGuardattached.session.user.isAdmin(the NextAuth session, set in the callback inauth.ts) and the admin-list DTO'sisAdminfield both exist for UI rendering only - the landing nav's Backoffice link and the users table respectively - and neither is what enforces access.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.tsis a wired@Cron(CronExpression.EVERY_HOUR)job that deletes rows whoseexpireshas passed fromsessions,verification_tokensandmagic_link_attempts, and rows older than 30 days frombilling_webhook_eventsandbilling_payment_hook_claims. All five DELETEs fire concurrently against the same pool. - Magic-link sentinel admin: the seeded MCP admin user uses email
[email protected]. The.invalidsuffix is RFC 6761 reserved (no MX possible) and the auth-adapter short-circuits magic-link issuance for it. Both halves are load-bearing: if you rename it to an address that can receive mail, anyone able to request a magic link for it can sign in as the MCP admin.
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)