"use server" function and every .get()/.post() route as a public endpoint.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:12345678910// 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 }
123456789// 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 }
requireSession() at the top of every action that reads or mutates user data — before any database call.state.session directly, resolved by the session middleware:123456789.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 }) // ... })
1234.layout('/app/*', async ({ loaderData, children }) => { if (!loaderData.session) throw redirect('/login') return <AppLayout>{children}</AppLayout> })
123456789101112131415export 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)) }
12345678910111213export 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 } }
12345678910111213.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 } })
parseFormData for forms and Zod for JSON:12345678910111213import { 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 }
123456789.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 }, })
123456789// 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 } }, })
12345test('unauthenticated user cannot access protected route', async () => { const anon = createSpiceflowFetch(app) const result = await anon('/api/projects') expect(result).toBeInstanceOf(Error) })
1234567891011121314151617test('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/) })
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.