CSRF edge cases in vibe-coded apps: SameSite isn't enough, tokens that don't actually verify, the bearer-token escape hatch.
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:
- Add the new POST endpoint that does the actual work.
- 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.
- 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:
- Marketing site on a CMS. WordPress install at
blog.your-app.comwith an outdated plugin gets owned. The attacker now runs JS onblog.your-app.com; the parent-domain cookie sends to their requests; they read it viadocument.cookieif it's notHttpOnly(XSS), or they make authenticated cross-subdomain requests (CSRF-via-trusted-subdomain). - Customer-uploaded subdomains. If your platform lets customers host content at
customer-foo.your-app.com, the cookie sends to those requests too. Customer Foo's HTML can fetch from your API as the visiting user. - Decommissioned subdomain. A subdomain you used in 2023, deleted in 2024. The DNS record stays; an attacker re-registers the same name on the same hosting service (CNAME takeover). Now they have a subdomain that browsers send the parent-domain cookie to.
The mitigation isn't "stop using cross-subdomain cookies" — sometimes you genuinely need them. The mitigation is awareness:
- Audit every subdomain. CT-log enumeration tools (
crt.sh) list every cert ever issued for your domain. vibecheck's?deep=1scan does this and flags high-signal subdomain prefixes. - Watch for dangling DNS. Tools like
dnstwistandSubjackPluscheck every subdomain for orphaned CNAMEs. - Don't host untrusted content on a subdomain. If users can publish under
customer-foo.your-app.com, separate that to a different apex (your-app-customer-content.com) so the parent-domain cookie doesn't reach it. - Scope cookies as tightly as possible. Default to setting cookies on the exact host (
app.your-app.com), not on the parent (.your-app.com). Only widen the scope when there's a real cross-subdomain need.
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:
- XSS amplifies catastrophically. If an attacker gets any JS execution on your origin (a stored XSS, a compromised npm package, a browser extension that injects), they read the bearer token directly from localStorage and exfiltrate it. With cookie-based session auth,
HttpOnlycookies are unreadable to JS — attacker still has session-level access via the page but can't move the credential off-site. With bearer tokens, the credential leaves with the attacker and works from anywhere until expiration. - Long-lived tokens become long-lived liabilities. See the JWT mistakes post — the access/refresh-token split exists precisely because access tokens in the browser need to be short-lived. Skipping the refresh-token flow gives attackers a 30-day window with the stolen token instead of 15 minutes.
- CORS becomes the security boundary. Without browser cookie auto-attach, your API's CORS policy is the only thing preventing attacker-page-fetches-with-the-victim's-token. CORS misconfigs in this world are not just inconvenient — they're the whole game.
The hardening:
- 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. - Short-lived access tokens. 15-60 minute expiration. Refresh-token flow stored in
HttpOnlycookies that are auto-attached but only on same-origin requests. - 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:
- The endpoint accepts both JSON and form bodies. Express with both
express.json()andexpress.urlencoded()middleware in the chain accepts either. A cross-site form posts urlencoded data; if the endpoint extracts fields by name regardless of body shape (req.body.usernameworks either way), the CSRF gets through. - The endpoint accepts
text/plain.fetch's default content-type from a cross-origin context istext/plainfor simple bodies, which is a CORS-safelisted content-type that doesn't trigger preflight. If the endpoint reads the body as text and parses JSON manually, an attacker can ship JSON via a fetch with no preflight, no Authorization header, but with the cookie auto-attached. - The endpoint accepts query-string parameters as a fallback. "We post JSON, but for legacy clients we also accept
?username=...&password=...in the URL." The query string works in a cross-site GET. The endpoint reads from either source.
Fix. Three rules per endpoint:
- Reject any non-JSON content type. Explicitly check
Content-Type: application/jsonat the top of the handler. Reject 415 if not. - 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).
- For
fetchfrom your own JS, setcredentials: '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:
- SameSite=Lax cookies (the browser default) for any session cookie. Add
HttpOnlyandSecure. - State changes go through POST/PUT/PATCH/DELETE. No state-changing GETs. No exceptions.
- JSON-only API endpoints with explicit content-type checks.
- Bearer-token auth (Supabase / Firebase / Clerk) is fine, paired with strict CSP, short-lived access tokens, and tight CORS.
- For traditional cookie-session apps, double-submit CSRF tokens via a battle-tested library —
csrf-csrffor Node, framework-built-ins where they exist. - Cookies scoped to the exact host, not the parent domain, unless cross-subdomain access is genuinely needed.
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:
cookie_missing_secure/cookie_missing_httponly/cookie_missing_samesite— auth-shaped cookies without the right flags.cors_origin_reflected_with_credentials— the CORS bug that combines lethally with bearer-token auth.csp_unsafe_inline_scriptsand the rest of the CSP family — the XSS-surface tightening that bearer-token apps depend on.
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