CSRF Protection
ideal-auth does not handle CSRF protection. This is by design — CSRF mitigation is a framework-level concern, and every major framework has its own mechanism. This guide explains the threat and walks through prevention strategies for each supported framework.
What is CSRF?
Section titled “What is CSRF?”Cross-Site Request Forgery (CSRF) is an attack where a malicious website tricks a user’s browser into making an authenticated request to your application. Because the browser automatically attaches cookies (including session cookies) to every request to your domain, the attacker can perform actions as the logged-in user without their knowledge.
Example attack:
- A user is logged into your application at
app.example.com - The user visits
evil.example.com, which contains a hidden form:<form action="https://app.example.com/api/transfer" method="POST"><input type="hidden" name="to" value="attacker" /><input type="hidden" name="amount" value="1000" /></form><script>document.forms[0].submit();</script> - The browser submits the form with the user’s session cookie attached
- Your server processes the transfer as if the user initiated it
This is why CSRF protection matters for any application that uses cookie-based authentication — which includes ideal-auth.
Why ideal-auth does not handle CSRF
Section titled “Why ideal-auth does not handle CSRF”CSRF protection requires intercepting incoming requests before they reach your application logic. This is inherently framework-specific:
- Next.js Server Actions have built-in protection
- SvelteKit form actions have built-in protection
- Express requires middleware
- Hono has a built-in middleware
Implementing CSRF at the auth library level would mean coupling to a specific framework’s request/response model, which contradicts ideal-auth’s framework-agnostic design. Instead, ideal-auth provides the SameSite cookie attribute (default: lax) as a foundational defense layer, and you add framework-specific CSRF protection on top.
Framework-specific strategies
Section titled “Framework-specific strategies”Server Actions have built-in CSRF protection. Next.js automatically validates the Origin header on all Server Action requests and rejects cross-origin submissions. No configuration is needed.
API Route Handlers (app/api/*/route.ts) do not have automatic CSRF protection. If you accept state-changing requests via API routes, validate the Origin header manually:
import { headers } from 'next/headers';import { NextResponse } from 'next/server';
export async function POST(request: Request) { const headerStore = await headers(); const origin = headerStore.get('origin'); const host = headerStore.get('host');
if (!origin || new URL(origin).host !== host) { return NextResponse.json( { error: 'Invalid origin' }, { status: 403 }, ); }
// ... handle the request}Form actions have built-in CSRF protection. SvelteKit validates the Origin header against the server’s host on all form action submissions and returns a 403 for mismatches.
This is enabled by default. You can verify it has not been disabled in your svelte.config.js:
const config = { kit: { // csrf.checkOrigin defaults to true — do not set it to false csrf: { checkOrigin: true, }, },};Custom API endpoints (+server.ts) do not receive automatic CSRF protection. Apply the same Origin header check:
import { error, json } from '@sveltejs/kit';import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request, url }) => { const origin = request.headers.get('origin'); if (!origin || new URL(origin).host !== url.host) { throw error(403, 'Invalid origin'); }
// ... handle the request return json({ ok: true });};Nuxt does not include built-in CSRF protection. Use the nuxt-csrf module or implement Origin header validation in server middleware:
Option 1: nuxt-csrf module
npx nuxi module add nuxt-csurfThe module automatically generates a CSRF token and validates it on form submissions.
Option 2: Manual Origin check
export default defineEventHandler((event) => { if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(event.method)) { const origin = getHeader(event, 'origin'); const host = getHeader(event, 'host');
if (!origin || !host || new URL(origin).host !== host) { throw createError({ statusCode: 403, message: 'Invalid origin', }); } }});TanStack Start does not include built-in CSRF protection. Validate the Origin header in your server functions:
export function validateOrigin(request: Request): void { const origin = request.headers.get('origin'); const host = request.headers.get('host');
if (!origin || !host || new URL(origin).host !== host) { throw new Error('CSRF validation failed'); }}Call this at the start of any server function that performs a state-changing operation:
import { validateOrigin } from '../lib/csrf';
export const serverFn = createServerFn('POST', async (data, ctx) => { validateOrigin(ctx.request); // ... handle the request});Use the double-submit cookie pattern or a dedicated CSRF middleware. The popular csrf-csrf package implements the double-submit pattern:
npm install csrf-csrfimport { doubleCsrf } from 'csrf-csrf';
const { doubleCsrfProtection, generateToken } = doubleCsrf({ getSecret: () => process.env.CSRF_SECRET!, cookieName: '__csrf', cookieOptions: { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/', }, getTokenFromRequest: (req) => req.headers['x-csrf-token'] as string,});
export { doubleCsrfProtection, generateToken };import express from 'express';import { doubleCsrfProtection } from './middleware/csrf';
const app = express();
// Apply CSRF protection to all state-changing routesapp.use(doubleCsrfProtection);Hono has a built-in csrf() middleware that validates the Origin header:
import { Hono } from 'hono';import { csrf } from 'hono/csrf';
const app = new Hono();
app.use(csrf());By default, this checks the Origin header against the request’s host. You can specify allowed origins:
app.use(csrf({ origin: ['https://app.example.com', 'https://staging.example.com'],}));Elysia does not include built-in CSRF protection. Validate the Origin header in a global onBeforeHandle hook:
import { Elysia } from 'elysia';
const app = new Elysia() .onBeforeHandle((ctx) => { if (ctx.request.method === 'GET' || ctx.request.method === 'HEAD') { return; }
const origin = ctx.request.headers.get('origin'); const host = ctx.request.headers.get('host');
if (!origin || !host || new URL(origin).host !== host) { ctx.set.status = 403; throw new Error('CSRF validation failed'); } });The double-submit cookie pattern
Section titled “The double-submit cookie pattern”If your framework does not provide built-in CSRF protection, the double-submit cookie pattern is a reliable framework-agnostic fallback. Here is how it works:
- On page load, the server sets a random CSRF token in a cookie (
sameSite: 'strict', nothttpOnly— JavaScript needs to read it) - On form submission, JavaScript reads the token from the cookie and includes it as a request header (e.g.,
X-CSRF-Token) - On the server, the middleware compares the token in the header to the token in the cookie. If they match, the request is legitimate
This works because:
- A cross-origin attacker can cause the browser to send cookies, but cannot read cookies belonging to your domain
- The attacker cannot set the
X-CSRF-Tokenheader because they cannot read the cookie value SameSite: stricton the CSRF cookie prevents the cookie from being sent in cross-origin requests at all
import { randomBytes } from 'node:crypto';
// Generate and set the CSRF token cookiefunction setCsrfToken(setCookie: (name: string, value: string, options: any) => void): string { const token = randomBytes(32).toString('hex'); setCookie('csrf_token', token, { httpOnly: false, // JavaScript must read this sameSite: 'strict', secure: process.env.NODE_ENV === 'production', path: '/', }); return token;}
// Validate the CSRF token on incoming requestsfunction validateCsrfToken( cookieToken: string | undefined, headerToken: string | undefined,): boolean { if (!cookieToken || !headerToken) return false; // Use timing-safe comparison return cookieToken.length === headerToken.length && require('node:crypto').timingSafeEqual( Buffer.from(cookieToken), Buffer.from(headerToken), );}SameSite cookies as a defense layer
Section titled “SameSite cookies as a defense layer”ideal-auth defaults to sameSite: 'lax' on session cookies. This provides baseline CSRF protection by itself:
| SameSite value | Behavior |
|---|---|
strict | Cookie is never sent in cross-origin requests. Maximum protection, but breaks legitimate navigation (e.g., clicking a link from an email) |
lax (default) | Cookie is sent for top-level navigations (GET) but not for cross-origin form submissions (POST). Protects against the most common CSRF vectors |
none | Cookie is always sent. Requires secure: true. No CSRF protection from this attribute |
SameSite: lax blocks the classic CSRF attack (cross-origin POST) while still allowing users to follow links to your app without losing their session. For most applications, this is the correct default.
Summary
Section titled “Summary”| Framework | Forms/Actions | API Routes |
|---|---|---|
| Next.js | Built-in (Server Actions) | Manual Origin check |
| SvelteKit | Built-in (form actions) | Manual Origin check |
| Nuxt | nuxt-csrf module or manual | nuxt-csrf module or manual |
| TanStack Start | Manual Origin check | Manual Origin check |
| Express | csrf-csrf or double-submit | csrf-csrf or double-submit |
| Hono | Built-in csrf() middleware | Built-in csrf() middleware |
| Elysia | Manual Origin check | Manual Origin check |