DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Server Actions Don't Need CSRF Protection, Your API Routes Still Do, and Mixing That Up Is a Real Hole

Somewhere along the way, a lot of Next.js developers picked up the idea that "Server Actions are secure by default" and quietly generalized that into "mutations in this app are secure by default." Those are not the same statement, and the gap between them is exactly where this bug lives.

Why Server Actions Actually Are Safe From CSRF

Next.js Server Actions include built-in CSRF protection out of the box. When a Server Action is called, Next.js checks the request's Origin header against the deployment's own host, and rejects the call if it doesn't match.

// actions/update-email.ts
'use server';

export async function updateEmail(formData: FormData) {
  const newEmail = formData.get('email') as string;
  const userId = await getCurrentUserId();
  await User.findByIdAndUpdate(userId, { email: newEmail });
}
Enter fullscreen mode Exit fullscreen mode

You didn't write any CSRF-handling code here, and you don't need to. Next.js's framework-level origin check means a malicious site trying to trigger this action from a hidden form or script on a different domain gets rejected before your function body ever runs. This protection is real, and it's one of the genuinely nice defaults Server Actions give you for free.

Where the Same Confidence Quietly Stops Applying

The problem shows up the moment the same app also has API routes doing equivalent mutations, which is extremely common, Server Actions for your own forms, API routes for anything that needs to be called from outside, a mobile app, a webhook, a public integration.

// app/api/users/update-email/route.ts
export async function POST(request: Request) {
  const { email, userId } = await request.json();

  // No origin check here. Nothing here is Next.js's built-in CSRF protection,
  // because that protection is specific to Server Actions, not API routes.
  await User.findByIdAndUpdate(userId, { email });

  return Response.json({ success: true });
}
Enter fullscreen mode Exit fullscreen mode

This route has no framework-provided CSRF protection at all. If it relies on a cookie-based session to identify the user rather than a token the calling code must explicitly attach, a malicious page on a completely different domain can trigger a request to this endpoint using the victim's own browser, which will happily attach their existing session cookie, and the server has no way to tell this request apart from a legitimate one made from your own app.

<!-- On attacker-controlled-site.com -->
<form action="https://yourapp.com/api/users/update-email" method="POST">
  <input type="hidden" name="email" value="attacker@evil.com" />
</form>
<script>document.forms[0].submit();</script>
Enter fullscreen mode Exit fullscreen mode

If a logged-in victim visits that page, their browser submits this form to your real API route, cookies and all, and your route processes it as if it came from your own frontend, because nothing in that route checks otherwise.

Why This Specifically Gets Missed

The mental model "Next.js handles CSRF" is correct for exactly one surface, Server Actions, and developers reasonably extend it to the whole app because the framework genuinely did remove the need to think about this for the most common mutation path. API routes feel like the same kind of thing, same app, same auth system, same database calls, so the assumption carries over even though the actual protection mechanism does not.

This also tends to get reinforced by testing, since every normal test of the API route happens from your own frontend, on your own origin, which is exactly the traffic pattern that looks identical whether or not CSRF protection exists.

The Fix: Treat API Routes as Needing Their Own Explicit Protection

If an API route is meant to only ever be called from your own frontend, verify the origin explicitly, the same check Server Actions do for you automatically:

// app/api/users/update-email/route.ts
function isSameOrigin(request: Request): boolean {
  const origin = request.headers.get('origin');
  const host = request.headers.get('host');
  if (!origin) return false;
  return new URL(origin).host === host;
}

export async function POST(request: Request) {
  if (!isSameOrigin(request)) {
    return Response.json({ error: 'Invalid origin' }, { status: 403 });
  }

  const { email, userId } = await request.json();
  await User.findByIdAndUpdate(userId, { email });
  return Response.json({ success: true });
}
Enter fullscreen mode Exit fullscreen mode

If the route genuinely needs to be called from outside your own frontend, don't rely on cookie-based session auth for it at all. Use an explicit bearer token or API key that an attacker's page has no way to attach automatically, which is the actual reason CSRF doesn't apply to most token-based API authentication in the first place, the browser doesn't auto-include a header a malicious page doesn't know the value of.

The Rule

"Next.js handles CSRF" is true for Server Actions specifically, not for every mutation path in your app. Any API route that mutates data and relies on cookie-based auth needs its own explicit protection, either an origin check or a move away from cookies toward an explicit token for that specific route.

I keep this distinction explicit in the auth layer of my Next.js dashboard templates, where Server Actions and API routes sit side by side and get treated very differently for exactly this reason. You can see the broader portfolio of templates and projects this pattern comes from at pixelanas.com.

Get the templates: https://pixelanas.gumroad.com

Go check your own API routes for anything that mutates data using cookie session auth with no origin check. If you find one, this is worth closing today. Drop what you find in the comments.


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)