hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Securing Your API with @hectoday/http

The Threat Landscape

  • What Could Go Wrong
  • Project Setup

Brute-Force Protection

  • Rate Limiting Login Attempts
  • Account Lockout
  • Timing Attack Prevention

CSRF Protection

  • What Is CSRF?
  • CSRF Tokens
  • CSRF for API Consumers

Token Hardening

  • Refresh Token Rotation
  • Token Revocation
  • Secure Token Storage

Password Reset

  • The Password Reset Flow
  • Building the Reset Routes
  • Reset Security

Putting It All Together

  • Security Headers
  • Logging and Monitoring
  • Security Checklist
  • Capstone: Hardened Auth API

CSRF Tokens

The idea

A CSRF token is a random secret that the server generates and the client includes in every state-changing request. The attacker cannot know the token because they cannot read your app’s pages (the same-origin policy prevents it).

The server checks: does this request include a valid CSRF token? If not, reject it.

The double-submit cookie pattern

There are several ways to implement CSRF tokens. We will use the double-submit cookie pattern because it works well with stateless APIs and does not require server-side token storage.

Here is how it works:

  1. The server sends a random token in a cookie (a separate cookie from the session cookie)
  2. The client reads the token from the cookie and includes it in a request header
  3. The server checks that the header value matches the cookie value

Why does this work? The attacker can trigger a request that includes the CSRF cookie (browsers attach cookies automatically). But the attacker cannot read the cookie from their page (same-origin policy), so they cannot set the matching header. The request arrives with the cookie but without the header, and the server rejects it.

Generate the CSRF token

Create src/csrf.ts:

// src/csrf.ts
const CSRF_COOKIE = "csrf_token";
const CSRF_HEADER = "x-csrf-token";

export function generateCsrfToken(): string {
  return crypto.randomUUID();
}

export function csrfCookie(token: string): string {
  // NOT HttpOnly — JavaScript needs to read this cookie to set the header
  return `${CSRF_COOKIE}=${token}; SameSite=Lax; Path=/; Max-Age=86400`;
}

export function verifyCsrf(request: Request): boolean {
  // Read the token from the cookie
  const cookieHeader = request.headers.get("cookie");
  const cookies = parseCookiesFromHeader(cookieHeader);
  const cookieToken = cookies[CSRF_COOKIE];

  // Read the token from the request header
  const headerToken = request.headers.get(CSRF_HEADER);

  // Both must exist and match
  if (!cookieToken || !headerToken) return false;
  return cookieToken === headerToken;
}

function parseCookiesFromHeader(header: string | null): Record<string, string> {
  if (!header) return {};
  const cookies: Record<string, string> = {};
  for (const pair of header.split(";")) {
    const [name, ...rest] = pair.trim().split("=");
    if (name) cookies[name] = rest.join("=");
  }
  return cookies;
}

Notice the CSRF cookie is not HttpOnly. This is intentional. The client JavaScript needs to read the cookie value to set the X-CSRF-Token header. The session cookie remains HttpOnly because JavaScript never needs to read it.

Set the token on every response

Use the onResponse hook to include the CSRF cookie in every response:

// In src/app.ts
import { generateCsrfToken, csrfCookie } from "./csrf.js";

onResponse: ({ request, response, locals }) => {
  const headers = new Headers(response.headers);

  // Set CSRF token if not already present
  const existingCookie = request.headers.get("cookie") ?? "";
  if (!existingCookie.includes("csrf_token=")) {
    headers.append("set-cookie", csrfCookie(generateCsrfToken()));
  }

  // ... other response modifications (logging, etc.)

  return new Response(response.body, {
    status: response.status,
    headers,
  });
},

The token is set once and persists via the cookie. If the cookie is already present (returning user), we skip generating a new one.

Verify on state-changing requests

Add CSRF verification to routes that change state. You can do this per-handler or create a helper:

// src/csrf.ts — add this
export function requireCsrf(request: Request): true | Response {
  if (!verifyCsrf(request)) {
    return Response.json({ error: "Invalid CSRF token" }, { status: 403 });
  }
  return true;
}

Use it in handlers:

route.post("/users", {
  resolve: async (c) => {
    const csrf = requireCsrf(c.request);
    if (csrf instanceof Response) return csrf;

    const caller = authenticatedAdmin(c.request);
    if (caller instanceof Response) return caller;

    // ... create user
  },
});

Same pattern as authenticate and requireAdmin: call the function, check with instanceof Response.

What the client does

The client JavaScript reads the CSRF cookie and includes it as a header:

// Client-side fetch example
function getCookie(name) {
  const match = document.cookie.match(new RegExp(`${name}=([^;]+)`));
  return match ? match[1] : null;
}

const response = await fetch("/users", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-CSRF-Token": getCookie("csrf_token"),
  },
  body: JSON.stringify({ name: "Alice", email: "[email protected]" }),
});

This is the client’s only responsibility: read the cookie, set the header.

When to require CSRF tokens

Require CSRF verification on:

  • POST, PUT, PATCH, DELETE routes (any state-changing method)
  • Routes that use cookie-based authentication (sessions)

Do not require CSRF verification on:

  • GET, HEAD, OPTIONS routes (these should not change state)
  • Routes that use token-based auth (Authorization header). Token-based auth is inherently CSRF-safe because the browser does not attach the token automatically.

Exercises

Exercise 1: Add CSRF token generation and verification to your app. Make a POST request without the X-CSRF-Token header and verify you get 403. Then include the header with the correct value and verify it succeeds.

Exercise 2: Try setting the X-CSRF-Token header to a random value that does not match the cookie. Verify you get 403. The header and cookie must match.

Exercise 3: Why is the CSRF cookie not HttpOnly? If it were, client JavaScript could not read it to set the header, and the double-submit pattern would not work. The cookie does not need to be secret from JavaScript on the same page — it needs to be secret from JavaScript on other pages (which the same-origin policy ensures).

Why does the double-submit cookie pattern work against CSRF?

Why is token-based auth (Authorization header) inherently CSRF-safe?

← What Is CSRF? CSRF for API Consumers →

© 2026 hectoday. All rights reserved.