Skip to content

CSRF Protection

Cross-Site Request Forgery prevention strategies for each framework.

Updated View as Markdown

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?

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:

  1. A user is logged into your application at app.example.com
  2. 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>
  3. The browser submits the form with the user’s session cookie attached
  4. 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

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

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:

app/api/example/route.tsts
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:

svelte.config.jsjs
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:

src/routes/api/example/+server.tsts
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-csurf

The module automatically generates a CSRF token and validates it on form submissions.

Option 2: Manual Origin check

server/middleware/csrf.tsts
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:

app/lib/csrf.tsts
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:

app/routes/api/example.tsts
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-csrf
src/middleware/csrf.tsts
import { 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 };
src/app.tsts
import express from 'express';
import { doubleCsrfProtection } from './middleware/csrf';

const app = express();

// Apply CSRF protection to all state-changing routes
app.use(doubleCsrfProtection);

Hono has a built-in csrf() middleware that validates the Origin header:

src/index.tsts
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:

src/index.tsts
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');
    }
  });

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:

  1. On page load, the server sets a random CSRF token in a cookie (sameSite: 'strict', not httpOnly — JavaScript needs to read it)
  2. On form submission, JavaScript reads the token from the cookie and includes it as a request header (e.g., X-CSRF-Token)
  3. 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-Token header because they cannot read the cookie value
  • SameSite: strict on the CSRF cookie prevents the cookie from being sent in cross-origin requests at all
Example: manual double-submit implementationts
import { randomBytes } from 'node:crypto';

// Generate and set the CSRF token cookie
function 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 requests
function 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

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

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
Navigation

Type to search…

↑↓ navigate↵ selectEsc close