CSRF and State-Changing GET Requests: A Vibe Coding Anti-Pattern - VibeDoctor 
← All Articles 🔒 Security Vulnerabilities High

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 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:

The OWASP CSRF Attack Page documents that state-changing GET requests are one of the easiest CSRF attack vectors because they require zero JavaScript on the attacker's side - a single image tag is enough. Veracode's 2024 State of Software Security report found that improper HTTP method usage appears in 18% of scanned web applications, with GET-based mutations being the most common form.

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 } from 'next/server';

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

// ❌ 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 POST with proper method
// 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: { id: string } }
) {
  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: params.id } });
  if (!post || post.authorId !== session.user.id) {
    return Response.json({ error: 'Forbidden' }, { status: 403 });
  }

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

// ✅ GOOD - Frontend calls DELETE via fetch, not a link href
function PostRow({ post }) {
  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 csurf middleware or use JWT Bearer tokens (immune to CSRF)
SPA with JWT in Authorization header Yes - header cannot be auto-sent by browser Keep JWT in memory or httpOnly cookie; use Bearer in headers
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 (the browser default since 2020) 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 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 (vibedoctor.io) automatically scan your codebase for state-changing GET requests and flag specific file paths and line numbers. 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 (introduced in Next.js 13.4) include built-in CSRF protection via an origin check. The framework validates that the Origin or Referer header matches the expected host before processing the action. However, custom API routes (route.ts files) do not get this protection - only Server Actions invoked through the React form mechanism.

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.

Scan your codebase for this issue - free

VibeDoctor checks for SEC-005 and 128 other issues across 15 diagnostic areas.

SCAN MY APP →
← Back to all articles View all 129+ checks →