CSRF edge cases in vibe-coded apps: SameSite isn't enough, tokens that don't actually verify, the bearer-token escape hatch.

2026-05-09 · vibecheck team · 11 min read · Auth · CSRF

Quick answer CSRF protections in 2026 mostly come from SameSite cookies — Lax is the modern default, and it blocks most cross-site state changes automatically. But "mostly" matters. Five edge cases consistently produce real CSRF in vibe-coded apps: GET endpoints that change state (SameSite=Lax permits cross-site GETs), CSRF tokens compared with === instead of a constant-time helper (timing-attack-able), cookies scoped to a parent domain so any subdomain takeover bypasses the boundary, the assumption that bearer-token auth (Supabase, Firebase, Clerk) is "CSRF-immune" — true for the cookie attack but creates new XSS-amplification problems, and JSON endpoints with permissive content-type handling that accept application/x-www-form-urlencoded from cross-site forms.

CSRF defence got considerably better around 2020 when browsers shipped SameSite=Lax as the default for cookies without an explicit flag. The class of "victim is logged into bank.com, attacker hosts a form that submits to bank.com/transfer, browser includes the cookie, transfer happens" mostly goes away with that one change.

"Mostly" still leaves five real failure modes. Each one ships in vibe-coded apps because the assumption is that SameSite handles everything, and it doesn't.

1. State-changing GETs

SameSite=Lax blocks cross-site cookies on POST, PUT, PATCH, DELETE — but explicitly permits them on top-level GET navigations. The reasoning was usability: links from email and other sites need to work without the user re-authenticating, so GET requests still carry cookies.

The trap: any endpoint that changes state on GET is bypassable. A vibe-coded app's "delete" link, "logout" button, or "subscribe to plan X" flow that uses a GET with query parameters becomes a CSRF target via:

<img src="https://victim-app.com/account/delete?confirm=true" />

The image tag in attacker-controlled HTML triggers a GET. The browser includes the cookie (Lax permits it on a top-level navigation, including image loads from a same-origin context). The endpoint executes the side effect.

The pattern shows up because dev tools make GET endpoints easy and "delete via GET" appears in many tutorials. app.get('/api/account/delete', ...) in Express. export async function GET() in a Next.js route handler that does anything other than read.

Fix. The HTTP method spec is the contract, not a suggestion: GET must be safe and idempotent. Side effects go through POST/PUT/PATCH/DELETE. If you're inheriting code that violates this, the migration:

  1. Add the new POST endpoint that does the actual work.
  2. Leave the GET endpoint in place but flip it to redirect-to-confirmation: GET shows a "are you sure?" page with a form that POSTs.
  3. Once frontend code is updated, retire the GET.

For non-form clients (CLIs, your own server-to-server traffic): they should already be using the right method.

2. Token comparison via ===

CSRF tokens (when you do use them — typically for traditional cookie-session apps) need to be compared with a constant-time equality function, not ===:

// THE BUG
if (req.body._csrf !== req.session.csrfToken) {
  return res.status(403).end();
}

String equality with === exits as soon as it finds a non-matching character. The exit time is observable from the network — a request whose token shares the first 4 characters of the real token returns slightly slower than one whose first character doesn't match. Iteratively, an attacker can guess the token character by character. Real attacks against this exist; the timing window is narrow but exploitable when the attacker is on the same network or the application has no rate limiting.

Fix. Use the platform's constant-time comparator:

// Node / runtime
import { timingSafeEqual } from "node:crypto";

function safeEqual(a: string, b: string): boolean {
  const ab = Buffer.from(a);
  const bb = Buffer.from(b);
  if (ab.length !== bb.length) return false;
  return timingSafeEqual(ab, bb);
}

if (!safeEqual(req.body._csrf, req.session.csrfToken)) {
  return res.status(403).end();
}

Same shape with crypto.subtle equality patterns in browser/edge runtimes. Web Crypto doesn't have a direct constant-time string-equal, but you can XOR two equal-length byte arrays and check the OR'd result.

If you're using a CSRF-token library (csurf, csrf-csrf, @hapi/crumb, etc.), it already does this. The bug shows up in hand-rolled implementations.

3. Parent-domain cookies

You set the auth cookie on .your-app.com so it works across app.your-app.com, api.your-app.com, and www.your-app.com. Convenient. Until any subdomain gets compromised:

The mitigation isn't "stop using cross-subdomain cookies" — sometimes you genuinely need them. The mitigation is awareness:

4. Bearer-token auth: not CSRF-vulnerable, but…

Supabase, Firebase, Clerk, Auth0, NextAuth's JWT mode — they all use bearer-token authentication: the auth token lives in localStorage / sessionStorage / a JS-readable cookie, and your client-side code attaches it to API requests via the Authorization: Bearer ... header.

This sidesteps classic CSRF entirely. Browsers don't auto-attach Authorization headers to cross-site requests; an attacker's page can't make a request with the victim's bearer token because the page can't read it (different origin = different localStorage).

The escape hatch creates its own problems:

The hardening:

  1. Tighten the XSS surface first. CSP done correctly (no 'unsafe-inline'), no innerHTML on user-controlled strings, dependency hygiene. Bearer-token apps are a step away from full takeover on any successful XSS — make XSS hard.
  2. Short-lived access tokens. 15-60 minute expiration. Refresh-token flow stored in HttpOnly cookies that are auto-attached but only on same-origin requests.
  3. Revocation list. Maintain a server-side denylist for tokens that should be invalidated immediately (logout, account deletion, role demotion). Stateless JWTs without a denylist are valid until expiration; for sensitive accounts, that's not acceptable.

The pattern of "we use Supabase Auth so we don't need CSRF tokens" is technically correct and operationally dangerous. It just shifts the threat from "cross-site request" to "any-XSS-anywhere is a token-grab."

5. Permissive content-type handling on JSON endpoints

Modern API endpoints accept JSON. Modern frontends post JSON. Cross-site forms cannot post JSON — browsers send forms as application/x-www-form-urlencoded or multipart/form-data, neither of which is JSON. So a JSON-only endpoint is implicitly CSRF-protected: a cross-site form's request body won't match the expected JSON shape, the JSON parser fails, the endpoint rejects.

That implicit protection breaks when:

Fix. Three rules per endpoint:

  1. Reject any non-JSON content type. Explicitly check Content-Type: application/json at the top of the handler. Reject 415 if not.
  2. Don't fall back to query-string params. Or if you must, gate the fallback behind the same CSRF check as a state-changing GET (see #1).
  3. For fetch from your own JS, set credentials: 'same-origin' explicitly. Default is 'same-origin' in modern browsers, but pinning it prevents accidentally widening to 'include' later.

Specific frameworks:

// Express
app.post('/api/x', (req, res) => {
  if (!req.is('application/json')) return res.status(415).end();
  // ...
});

// Next.js Route Handler
export async function POST(req: Request) {
  if (req.headers.get('content-type') !== 'application/json') {
    return new Response('expected JSON', { status: 415 });
  }
  // ...
}

// Hono
app.post('/api/x', async (c) => {
  if (c.req.header('content-type') !== 'application/json') {
    return c.text('expected JSON', 415);
  }
  // ...
});

The 2026-default CSRF stack

For a vibe-coded app starting fresh in 2026:

You don't need a hand-rolled CSRF token system unless you're doing classic server-rendered cookie-session work. For most vibe-coded apps, the SameSite default plus the JSON content-type check covers it — and the residual risk is in the bearer-token / XSS-amplification axis, not classic CSRF.

How vibecheck fits in

The CSRF edge cases above are mostly server-side and not externally-detectable from a static scan. What vibecheck does check from outside:

The structural patterns — SameSite vs. tokens, content-type checks, parent-domain scoping, constant-time comparison — are review-time work. Run a scan to catch the externally-visible parts; do this checklist on the rest.

Inspect your app's auth surface