← All Articles 🔒 Security Vulnerabilities High By Nishant Kumar Verma Updated

CSRF and State-Changing GET Requests: A Vibe Coding Anti-Pattern

AI code generators often use GET requests for mutations like delete and update. Learn why this creates CSRF vulnerabilities and how to fix it.

SEC-005

Quick Answer

A state-changing GET request is one that modifies data - deletes a record, transfers funds, cancels a subscription - in response to an HTTP GET. Because browsers, link prefetchers, and email clients load GET URLs automatically, any GET endpoint that performs a write operation can be triggered without the user's knowledge. AI tools generate this pattern constantly because it is the simplest way to wire up a "delete" or "update" button.

HTTP Semantics: GET Is for Reading, Not Writing

The HTTP specification (RFC 9110) is explicit: GET requests must be safe and idempotent. Safe means no side effects - loading the same URL twice should not change any state on the server. Idempotent means repeating the request produces the same result as doing it once. These are not arbitrary conventions; they exist because browsers, proxies, CDNs, and crawlers all treat GET requests as harmless and will repeat them freely.

When an AI tool generates GET /api/delete-user?id=42 or GET /api/approve-order?orderId=99, it creates a URL that can be triggered by:

  • A link in a phishing email that the victim clicks
  • An <img src="https://yourapp.com/api/delete-user?id=42"> on an attacker's page loaded by the victim's browser
  • A browser prefetcher that speculatively loads links on a page
  • A web crawler or security scanner that indexes GET endpoints
  • A client or library that automatically retries the GET after a dropped connection, which the spec allows for idempotent methods

The OWASP CSRF Attack Page shows how easy a forged GET request is: it needs zero JavaScript on the attacker's side - a single image tag is enough.

What AI Code Generates for "Delete" Operations

When you prompt Bolt, Cursor, or v0 to "add a delete button to each row in this table," the fastest implementation is a link or an anchor tag pointing to a GET endpoint. This is what it often looks like:

// ❌ BAD - State-changing GET endpoint
// app/api/delete-post/route.ts (Next.js)
import { db } from '@/lib/db';
import { NextRequest, NextResponse } from 'next/server';

export async function GET(req: NextRequest) {
  const postId = req.nextUrl.searchParams.get('id');
  await db.post.delete({ where: { id: postId } });
  return NextResponse.redirect(new URL('/posts', req.url));
}

// ❌ BAD - Frontend that calls it with a plain link
function PostRow({ post }) {
  return (
    <tr>
      <td>{post.title}</td>
      <td>
        {/* This URL can be triggered by anyone with a link */}
        <a href={`/api/delete-post?id=${post.id}`}>Delete</a>
      </td>
    </tr>
  );
}

This is a functional delete mechanism, and it will appear to work perfectly during development. The security problem only manifests when an attacker crafts a URL and tricks a logged-in user into loading it.

The Correct Pattern: POST with CSRF Token or SameSite Cookie

// ✅ GOOD - Mutation via DELETE, not GET
// app/api/posts/[id]/route.ts (Next.js App Router)
import { db } from '@/lib/db';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';

export async function DELETE(
  req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params; // params is a Promise since Next.js 15
  const session = await getServerSession(authOptions);
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Verify the post belongs to the calling user
  const post = await db.post.findUnique({ where: { id } });
  if (!post || post.authorId !== session.user.id) {
    return Response.json({ error: 'Forbidden' }, { status: 403 });
  }

  await db.post.delete({ where: { id } });
  return Response.json({ success: true });
}

// ✅ GOOD - Frontend calls DELETE via fetch, not a link href
// components/PostRow.tsx ('use client')
import { useRouter } from 'next/navigation';

function PostRow({ post }) {
  const router = useRouter();
  const handleDelete = async () => {
    if (!confirm('Delete this post?')) return;
    await fetch(`/api/posts/${post.id}`, { method: 'DELETE' });
    router.refresh();
  };

  return (
    <tr>
      <td>{post.title}</td>
      <td><button onClick={handleDelete}>Delete</button></td>
    </tr>
  );
}

Using DELETE (or POST) for mutations means the request cannot be triggered by a link click, an image tag, or a browser prefetch. Cross-origin POST requests from a malicious site still work in theory, which is why cookie-based sessions should use SameSite=Strict or SameSite=Lax to prevent the cookie from being sent in cross-origin requests.

CSRF Protection by Framework

Framework / Stack Default CSRF Protection? Recommended Approach
Next.js App Router No built-in CSRF tokens Use SameSite=Lax cookies + check Origin header in API routes
Next.js with next-auth Partial - form endpoints protected Rely on next-auth CSRF token for auth; add per-route checks for mutations
Express + Supabase No Add CSRF middleware such as csrf-csrf (csurf is deprecated) or use JWT Bearer tokens in the Authorization header (immune to CSRF)
SPA with JWT in Authorization header Yes - header cannot be auto-sent by browser Keep the JWT in memory and send it as a Bearer header (a cookie-stored token is auto-sent and needs CSRF protection)
Server-rendered app with session cookie No CSRF token per form + SameSite=Strict on session cookie

Same-Site Cookies as a Defense Layer

Modern SameSite cookie attributes dramatically reduce CSRF risk. Setting SameSite=Lax (what Chrome has applied since 2020 to cookies with no SameSite attribute; not every browser does this, so set it explicitly) means the session cookie is sent on top-level navigation GET requests but not on cross-origin subresource requests. SameSite=Strict is even more restrictive - no cross-site requests at all. This means an attacker's <img> tag or POST form submission from a different domain will not carry the victim's session cookie.

However, SameSite cookies do not help if the mutation uses GET - because GET navigations do send Lax cookies. This is the exact scenario where state-changing GETs become exploitable: a top-level navigation to https://yourapp.com/api/delete-account?confirm=1 will carry the user's Lax cookie and execute the deletion.

How to Fix State-Changing GET Requests

The audit process is straightforward. Search your codebase for all GET handlers - Next.js export async function GET, Express app.get(), and similar - and check whether the handler performs any database write operation. Any .create(), .update(), .delete(), .upsert(), or equivalent call inside a GET handler is a finding.

Migrate each one to the appropriate HTTP method: DELETE for deletion, PUT or PATCH for updates, POST for creation. Update the frontend to use fetch(url, { method: 'DELETE' }) or the equivalent rather than a plain anchor href.

Tools like VibeDoctor's Vibe Check automatically flag Express, Fastify, Koa, and NestJS GET routes whose path names a mutation (such as /delete, /update, or /reset), with specific file paths and line numbers. It does not yet read Next.js route handlers, so audit those with the search above. Free to sign up.

FAQ

Does using POST automatically protect against CSRF?

Using POST prevents the simplest attacks (image tags, link prefetch) but does not eliminate CSRF entirely. A cross-origin HTML form can submit a POST request with the victim's cookies if SameSite is not set to Strict or Lax. Full protection requires both using the correct HTTP method and ensuring cookies have a SameSite attribute, or using JWT Bearer tokens in Authorization headers.

If my app uses JWT Bearer tokens, am I safe from CSRF?

Yes, for the most part. JWT tokens stored in memory or localStorage must be explicitly attached to requests via the Authorization header - a cross-origin form or image tag cannot auto-attach them. This is one advantage of header-based auth over cookie-based auth. The tradeoff is that localStorage tokens are accessible to JavaScript, creating XSS risk.

Are Next.js Server Actions vulnerable to CSRF?

Next.js Server Actions (stable since Next.js 14) include built-in CSRF protection. They only accept POST, and Next.js compares the Origin header to the Host header (or X-Forwarded-Host) and aborts the request if they do not match. However, custom API routes (route.ts files) do not get this protection - only Server Actions do.

What about "safe" GET requests that trigger emails or notifications?

Sending an email or creating a notification is a side effect - it counts as a state change even though it does not modify a database row. A GET request that sends an email on behalf of the caller can be used to spam users or trigger password reset floods. Move these to POST endpoints with proper rate limiting.

How does GitHub Copilot or Cursor generate this pattern?

These tools learn from training data that includes countless tutorial examples and Stack Overflow answers that prioritize brevity. "Add a delete link" is solved fastest with a GET URL. The models have seen this pattern thousands of times in training data and reproduce it confidently. The solution is to review every mutation endpoint before deploying, not to trust the model's security judgment.

Diagnose your codebase - free

VibeDoctor checks for SEC-005 and 148 other issues across 21 diagnostic areas - security, performance, code quality, and more.

SCAN MY APP →