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
})
Fail-closed by default

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.

Why this matters

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:

ConcernWhat you get
CSRFOrigin/Referer check on cookie-authenticated mutations (bearer/tokenless clients are exempt). Automatic in /api.
SessionsSealed cookies — HttpOnly, Secure, SameSite=Lax; no cookie for anonymous callers.
Rate limitingcreateRateLimiter({ limit, windowMs }) — pure, clock-injectable; key by IP with rateLimitKey(event).
HeaderssecurityHeaders() / applySecurityHeaders(event) — conservative defaults, add your own CSP.
SSR islandLoaders 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.