usability.cat
Issue Wiki

Missing CSRF Protection

Leaving your car unlocked with the engine running — that's a form without CSRF protection. Anyone can take it for a ride.

What is this?

You parked your car, left it unlocked with the engine running, and went into the shop. Anyone can walk up, hop in, and drive away. That is what Cross-Site Request Forgery (CSRF) looks like. An attacker tricks your visitor's browser into making a request to your site (transferring money, changing their email, deleting their account) without the visitor knowing. The browser automatically includes cookies, so your server thinks the request is legitimate.

Why it matters

  • For your visitors: A CSRF attack can change their password, transfer their funds, modify their account settings, or make purchases — all without their knowledge. The visitor just clicked a link in an email or visited a malicious page, and their account on your site was compromised behind the scenes.
  • For your business: CSRF can lead to unauthorized transactions, data modification, and account takeovers. If a user's account is compromised because your forms lack CSRF protection, the liability falls on you. This is especially critical for financial transactions, account management, and any state-changing operations.
  • The standard: Every form that performs a state-changing action (POST, PUT, DELETE) should include a CSRF token — a unique, unpredictable value tied to the user's session. The server validates this token on every submission. Modern frameworks like Next.js provide built-in mechanisms for this.
Form with CSRF protection
<form method="POST" action="/api/update-email">
  <input type="hidden" name="csrf_token" value="a7x9k2m..." />
  <input type="email" name="email" />
  <button type="submit">Update Email</button>
</form>
Form without CSRF token
<form method="POST" action="/api/update-email">
  <input type="email" name="email" />
  <button type="submit">Update Email</button>
  <!-- No CSRF token — anyone can forge this request -->
</form>

How to fix it

React / Next.js

Next.js Server Actions have built-in CSRF protection — they automatically validate the Origin header. If you are using Server Actions, you are already partially protected. For extra security, add explicit token validation.

// Using Server Actions (built-in CSRF protection via Origin header)
async function updateEmail(formData: FormData) {
  "use server";
  const email = formData.get("email") as string;
  // Server Actions validate Origin automatically
  await db.updateUserEmail(userId, email);
}

function EmailForm() {
  return (
    <form action={updateEmail}>
      <input type="email" name="email" required />
      <button type="submit">Update Email</button>
    </form>
  );
}

For API routes, implement CSRF token validation:

// src/lib/csrf.ts
import { randomBytes } from "crypto";
import { cookies } from "next/headers";

export async function generateCsrfToken(): Promise<string> {
  const token = randomBytes(32).toString("hex");
  const cookieStore = await cookies();
  cookieStore.set("csrf-token", token, {
    httpOnly: true,
    sameSite: "strict",
    secure: process.env.NODE_ENV === "production",
  });
  return token;
}

export async function validateCsrfToken(token: string): Promise<boolean> {
  const cookieStore = await cookies();
  const storedToken = cookieStore.get("csrf-token")?.value;
  return storedToken === token && token.length > 0;
}
// In your form component
import { generateCsrfToken } from "@/lib/csrf";

async function SettingsForm() {
  const csrfToken = await generateCsrfToken();

  return (
    <form method="POST" action="/api/settings">
      <input type="hidden" name="csrf_token" value={csrfToken} />
      <input type="email" name="email" />
      <button type="submit">Save</button>
    </form>
  );
}

Plain HTML

Include a CSRF token in every form and validate it server-side.

<!-- Server generates a unique token per session -->
<form method="POST" action="/api/update-profile">
  <input type="hidden" name="csrf_token" value="SERVER_GENERATED_TOKEN" />
  <input type="text" name="name" />
  <button type="submit">Save</button>
</form>

<!-- For AJAX requests, include the token in a header -->
<script>
  const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;

  fetch("/api/update-profile", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": csrfToken,
    },
    body: JSON.stringify({ name: "New Name" }),
  });
</script>

Also set the SameSite attribute on your cookies as an additional layer of defense:

Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
High impactsecurity~2 paws

CSRF protection is like locking your car — it is so basic that not doing it is negligent. The cat expects every state-changing form to be protected.

How the cat scores this

The scanner checks all forms with method="POST" (and other state-changing methods) for the presence of a CSRF token — typically a hidden input field with a name like csrf_token, _csrf, authenticity_token, or similar. It also checks for the SameSite cookie attribute on session cookies. Forms that submit to external URLs are excluded from this check. Server Actions in Next.js get partial credit for their built-in Origin validation.

Further reading

On this page