Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.

CSRF, authentication, and authorization

CSRF protection (the Origin header check on server actions) blocks cross-site form submissions. It does not authenticate the caller. Any HTTP client can call a server action or API route directly. Treat every "use server" function and every .get()/.post() route as a public endpoint.
This guide assumes better-auth for authentication and Drizzle for database queries.

Authentication

Server actions

getActionRequest() gives you the action's own request — use it to resolve the session. Extract a shared requireSession() helper so every action is one line:
// src/auth-helpers.ts import { getActionRequest } from 'spiceflow' import { auth } from './auth.js' export async function requireSession() { const request = getActionRequest() const session = await auth.api.getSession({ headers: request.headers }) if (!session) throw new Error('unauthorized') return session }
// src/actions.ts 'use server' import { ulid } from 'ulid' import { requireSession } from './auth-helpers.js' export async function deleteProject(orgId: string, projectId: string) { const session = await requireSession() // ... authorization check, then mutation }
Call requireSession() at the top of every action that reads or mutates user data — before any database call.

API routes

API routes don't use loaders. Read state.session directly, resolved by the session middleware:
.state('session', null as AuthSession) .use(async ({ request, state }) => { state.session = await auth.api.getSession({ headers: request.headers }) }) .get('/api/projects', ({ state }) => { if (!state.session) return new Response('Unauthorized', { status: 401 }) // ... })

Pages and loaders

Loaders and pages can redirect unauthenticated users to the login page. A layout guard protects all nested pages at once:
.layout('/app/*', async ({ loaderData, children }) => { if (!loaderData.session) throw redirect('/login') return <AppLayout>{children}</AppLayout> })

Authorization

Authentication proves who the caller is. Authorization proves they own the resource. Always check both.

Owner check

Fetch the resource only if it belongs to the current user. A single query covers both lookup and ownership:
export async function deleteProject(orgId: string, projectId: string) { const session = await requireSession() // Only returns the project if the user owns the parent org const project = await db.query.project.findFirst({ where: { id: projectId, orgId, organization: { ownerId: session.user.id }, }, }) if (!project) throw new Error('not found or forbidden') await db.delete(schema.project).where(orm.eq(schema.project.id, projectId)) }

Membership check

For multi-tenant apps where many users can belong to an org, check membership before accessing any resource in that org:
export async function createProject(orgId: string, name: string) { const session = await requireSession() // Verify user is a member of the org const membership = await db.query.orgMember.findFirst({ where: { orgId, userId: session.user.id }, }) if (!membership) throw new Error('forbidden') const id = ulid() await db.insert(schema.project).values({ id, name, orgId, createdAt: new Date() }) return { id, name, orgId } }
Do the same check in loaders before returning org data:
.loader('/orgs/:orgId/dashboard', async ({ params, state }) => { if (!state.session) return { org: null, projects: [] } const membership = await db.query.orgMember.findFirst({ where: { orgId: params.orgId, userId: state.session.user.id }, }) if (!membership) return { org: null, projects: [] } const projects = await db.query.project.findMany({ where: { orgId: params.orgId }, }) return { org: { id: membership.orgId }, projects } })

Input Validation

Validate all user-supplied input before using it — before any database call. Use parseFormData for forms and Zod for JSON:
import { z } from 'zod' import { parseFormData } from 'spiceflow' const createProjectSchema = z.object({ orgId: z.string().min(1), name: z.string().min(1).max(100), }) export async function createProject(formData: FormData) { const session = await requireSession() const { orgId, name } = parseFormData(createProjectSchema, formData) // orgId and name are now validated strings — safe to use in queries }
For API routes with a request body schema, spiceflow validates automatically:
.route({ method: 'POST', path: '/api/projects', request: z.object({ orgId: z.string().min(1), name: z.string().min(1).max(100) }), async handler({ body, state }) { if (!state.session) return new Response('Unauthorized', { status: 401 }) // body is validated and typed }, })

Data Isolation

Never query a list without scoping it to the current user. Always filter by the user's identity or membership:
// Scoped to the current user's orgs only const orgs = await db.query.organization.findMany({ where: { ownerId: session.user.id }, }) // With membership table — only orgs the user belongs to const orgs = await db.query.organization.findMany({ where: { members: { userId: session.user.id } }, })

Testing Security

Tests should explicitly verify that unauthenticated requests and cross-user access are rejected. See Testing with Vitest for the full testing setup, especially the "Testing with Authentication" and "Testing with better-auth" sections.
Two patterns to always cover:
Unauthenticated access — verify the action or route rejects requests without a session:
test('unauthenticated user cannot access protected route', async () => { const anon = createSpiceflowFetch(app) const result = await anon('/api/projects') expect(result).toBeInstanceOf(Error) })
Cross-user access — verify user B cannot read or mutate user A's resources:
test('user cannot delete another users project', async () => { // Set up Alice with an org and project const aliceToken = await signupAndGetToken('alice@example.com') const alice = createSpiceflowFetch(app, { headers: { authorization: `Bearer ${aliceToken}` } }) const org = await runAction(() => createOrg('Alice Org'), { request: authedRequest(aliceToken) }) const project = await runAction(() => createProject(org.id, 'Alice Project'), { request: authedRequest(aliceToken) }) // Bob tries to delete Alice's project const bobToken = await signupAndGetToken('bob@example.com') const error = await runAction( () => deleteProject(org.id, project.id), { request: authedRequest(bobToken) }, ).catch((e) => e) expect(error).toBeInstanceOf(Error) expect(error.message).toMatch(/forbidden|not found/) })
Use runAction with a custom request to pass auth tokens to actions that call getActionRequest(). See example-better-auth tests for a complete working example covering these patterns.

Error Responses

Unhandled API errors return a JSON body with the message and stack. Secrets inside them (tokens, API keys, connection string passwords) are always redacted. To hide stack traces from clients, create the root app with noStackTraces:
const app = new Spiceflow({ noStackTraces: true })
See Stack traces and redaction for what is redacted.