Frontend: NextAuth Setup
Adapter (frontend/lib/auth-adapter.ts)
BackendAdapter() implements every NextAuth Adapter method Auth.js calls. 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} |
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 provider list, custom pages and callbacks (just the GitHub signIn guard by default), kept free of Node-only APIs so an Edge Runtime entry point can import it. Only auth.ts imports it today; proxy.ts does not. auth.ts spreads these callbacks into its own, except session, which auth.ts defines itself.
The GitHub provider overrides the default userinfo request so only a verified email is ever used: the public profile email (GitHub only lets a verified one be public), else the verified primary, else any verified email. The default request takes the primary email without checking verified, which allows pre-account takeover. A signIn callback refuses a GitHub sign-in that ends up with no email.
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).
A second cap keyed by client IP stops one client spreading requests over many addresses (or user+1@, user+2@ variants of one) to take a full per-email allowance for each. It lives in the same table under an ip:<key> key, defaults to 15 per window (MAGIC_LINK_IP_MAX_ATTEMPTS), and is checked and recorded in the same call as the email cap: the request is denied if either is over. Only the frontend knows the client IP, so it sends it in the check body (clientIp) and the backend trusts it because that route requires INTERNAL_API_TOKEN. Trust assumption: the frontend takes the last element of X-Forwarded-For (frontend/lib/client-ip.ts), which holds only behind a proxy that sets or overwrites the header, as the shipped Caddy does. Without such a proxy a client can choose its own address and so escape the IP cap, though never the per-email one. Where the last hop is instead an ingress address many clients share, set CLIENT_IP_HEADER to a header the platform sets to the client address and overwrites on every request (cf-connecting-ip behind Cloudflare); when that header holds an IP it is used in place of X-Forwarded-For. The same assumption moves to that header: on a platform that does not overwrite it, a client chooses its own address. With no header, or one that is not an IP, only the per-email cap applies. The backend validates the address and keys it as follows: IPv4 per address, IPv6 by its /64 (so a client rotating source addresses inside its own block shares one budget), and IPv4-mapped IPv6 (::ffff:1.2.3.4) as the IPv4 address. An address that is not a valid IP is ignored, falling back to the per-email cap alone. A client with several /64s, or many IPv4 addresses, still gets one budget per block or address.
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.