Auth
One policy. Pages, REST, and MCP.
Auth in Apex is a single, server-side, fail-closed policy applied to every
surface — page loaders, REST /api/*, and the MCP /mcp endpoint — so an
AI calling your tools can never do more than the logged-in user. Neither Next nor Nuxt governs
the AI surface this way.
Setup #
Scaffold the resolver and a login/logout pair, then set a session secret:
apex make auth # → server/auth.ts + server/api/login.ts + logout.ts
apex.config.ts — a 32+ char secret (override via APEX_SESSION_PASSWORD)
import { defineConfig } from '@apex-stack/core'
export default defineConfig({
runtimeConfig: { sessionPassword: process.env.APEX_SESSION_PASSWORD },
})
Identify the user #
defineAuth in server/auth.ts answers "who is this request?" once per
request. Its result is injected as user into every loader, route handler, and MCP
tool call — null means anonymous. Return whatever shape you like.
server/auth.ts
import { sessionAuth } from '@apex-stack/core/server'
// Reads the sealed session cookie written by /api/login. Or write your own
// defineAuth({ resolve }) to read a JWT / header / adapter session.
export default sessionAuth({ password: process.env.APEX_SESSION_PASSWORD! })
Gate a route #
Add auth: true to require a user, and an optional can for fine-grained
checks on the user and the validated input:
import { defineApexRoute } from '@apex-stack/core'
export default defineApexRoute({
method: 'DELETE',
auth: true, // anonymous → 401
can: ({ user }) => user.roles.includes('admin'), // not allowed → 403
handler: ({ user }) => removeThing(user),
})
REST returns 401 (no user) or 403 (not permitted). Over MCP, a route
the caller can't reach is omitted from tools/list per-user
and refused on tools/call — enforced at the handler layer, never
middleware-only.
Resource access & scope #
For defineResource/defineModel, gate each operation with
access and filter rows per-caller with scope. scope is
applied to every op — a WHERE on list/get/update/delete and a
stamp on create — so a caller only ever sees or touches their own rows.
models/note.ts
import { defineModel } from '@apex-stack/data'
export default defineModel('note', {
fields: { ownerId: 'string', body: 'text' },
access: { list: 'authed', get: 'authed', create: 'authed', update: 'authed', delete: 'authed' },
scope: ({ user }) => ({ ownerId: user.id }), // row-level: only your rows
})
Declaring access or scope gates the whole resource — any
op you don't list defaults to authed. You can't half-secure a resource and leave
delete open. create stamps ownership from scope (a
client can't spoof ownerId), and update can't reassign a scoped
column. Guessing another user's id returns null — no enumeration oracle.
Sessions #
The built-in session is a sealed (encrypted + signed) cookie — HttpOnly,
Secure, SameSite=Lax. Route handlers receive event; pass
it to the session helpers. Anonymous requests get no cookie.
server/api/login.ts
import { defineApexRoute } from '@apex-stack/core'
import { login, setStatus } from '@apex-stack/core/server'
import { z } from 'zod'
export default defineApexRoute({
method: 'POST',
input: { email: z.string(), password: z.string() },
handler: async ({ input, event }) => {
const user = await verify(input) // your credential check → user | null
if (!user) { setStatus(event, 401); return { error: 'Invalid credentials' } }
await login(event, { user }, { password: process.env.APEX_SESSION_PASSWORD! })
return { ok: true }
},
})
logout(event, { password }) clears the cookie. Prefer an adapter (below) for real
credential storage, OAuth, or 2FA.
The AI surface #
Because every typed route is also an MCP tool, auth has to reach the AI too — and it does, from
the same policy. Per request, the tool list is filtered to what the caller may reach, and every
tools/call re-checks authorization with the real arguments (defense-in-depth). A
scoped resource returns only the caller's rows over MCP exactly as over REST.
Next and Nuxt secure pages and API routes, but neither has an AI-callable surface to govern.
Apex's moat — every route is an MCP tool — would be a liability without this: here the same
auth/can/access/scope that guards REST
guards the tools your AI can call. See AI-native APIs.
Hardening #
Because Apex owns the server, sensible defaults ship in @apex-stack/core/server:
| Concern | What you get |
|---|---|
| CSRF | Origin/Referer check on cookie-authenticated mutations (bearer/tokenless clients are exempt). Automatic in /api. |
| Sessions | Sealed cookies — HttpOnly, Secure, SameSite=Lax; no cookie for anonymous callers. |
| Rate limiting | createRateLimiter({ limit, windowMs }) — pure, clock-injectable; key by IP with rateLimitKey(event). |
| Headers | securityHeaders() / applySecurityHeaders(event) — conservative defaults, add your own CSP. |
| SSR island | Loaders decide what serializes; never bake secrets into the hydration state. |
Adapters #
OAuth providers, JWT issuance, and 2FA are their own products — Apex doesn't reinvent them.
Bring an adapter (Lucia, Better-Auth, Auth.js) and surface its user from
defineAuth: read its session in resolve, or map its payload with
sessionAuth's toUser. Everything above — route gating, resource
access, scope, the MCP surface — works unchanged on top of whatever identity you return.