I wrote a post a while back about Server Actions being public, directly callable endpoints regardless of your UI, and a fair number of comments pushed back with some version of "sure, but at least they're not vulnerable to CSRF like a regular form post would be." That pushback is actually correct, and it's worth explaining exactly why, because the same protection does not automatically extend to a plain API route handler doing the same job.
What CSRF Actually Is, Quickly
Cross-Site Request Forgery is when a malicious site gets a victim's browser to send a request to your app, riding on the victim's existing authenticated session cookie, without the victim knowing it happened. If your app trusts any authenticated request regardless of where it originated from, a malicious site can trigger real actions, transferring funds, changing account settings, on behalf of a logged-in user who never intended to do any of it.
Why Server Actions Are Actually Safer Here by Default
Next.js automatically checks the Origin header on every Server Action invocation and compares it against your app's own host. A request claiming to be a Server Action call, but arriving with an Origin header that doesn't match your deployed domain, gets rejected automatically, before your action's own code even runs.
// actions/transfer.ts
'use server';
export async function transferFunds(amount: number, toAccount: string) {
// If this function is even reached, Next.js already verified
// the request's Origin header matched this app's own domain
const session = await getSession();
// ...
}
You didn't write any of that origin checking. It's built into how Server Actions work, specifically because they're designed to be called from your own app's forms and components, and Next.js has enough context, its own build output, its own deployed domain, to verify that automatically.
Why Route Handlers Don't Get This for Free
A route handler in app/api/ is a general-purpose HTTP endpoint. Next.js has no built-in assumption about who's allowed to call it, a mobile app, a webhook from an external service, your own frontend, a completely different website. Because that flexibility is the whole point of a route handler, Next.js can't safely assume every request should come from your own origin, so it doesn't check.
// app/api/transfer/route.ts
export async function POST(request: Request) {
const session = await getSession();
if (!session) return new Response('Unauthorized', { status: 401 });
const { amount, toAccount } = await request.json();
// Nothing here checked where this request actually came from
// A malicious site's form, posting to this exact URL, works identically
// to your own frontend calling it, as long as the victim's session cookie rides along
}
If a user is logged into your app in one tab, and a malicious site in another tab has a hidden form auto-submitting a POST request to this exact URL, the browser happily attaches the session cookie, and this handler has no way to tell the difference between that and a legitimate request from your own frontend, unless you explicitly add a check.
The Fix for Route Handlers
// app/api/transfer/route.ts
export async function POST(request: Request) {
const origin = request.headers.get('origin');
const allowedOrigin = process.env.NEXT_PUBLIC_URL;
if (origin !== allowedOrigin) {
return new Response('Forbidden', { status: 403 });
}
const session = await getSession();
if (!session) return new Response('Unauthorized', { status: 401 });
// proceed
}
This is a real, explicit version of the same check Server Actions get automatically. Not every route handler needs it, a genuinely public GET endpoint returning non-sensitive data doesn't care where the request came from, but anything that mutates state on behalf of an authenticated user, exactly the kind of endpoint CSRF actually targets, needs this check if it's a route handler rather than a Server Action.
This Doesn't Mean Server Actions Are Immune to Everything
This is worth being precise about, since it's easy to overcorrect into "Server Actions are just safe, full stop." Origin checking protects specifically against CSRF, a request forged from a different site riding on a victim's session. It does nothing about authorization, whether the currently authenticated user is actually allowed to do what they're asking to do. That's the exact gap the earlier post covered, a Server Action correctly rejecting a cross-origin CSRF attempt can still happily let an authenticated user delete a different user's account, if the action trusts a client-supplied ID instead of deriving identity from the session. Origin checking and authorization are two separate, both necessary layers, and having one does not mean you can skip the other.
The Actual Takeaway
Server Actions get CSRF protection automatically, via Origin header verification, without you writing any code for it. Route handlers get none of that by default, and need an explicit origin check for anything that mutates state on behalf of an authenticated session. Neither one automatically handles authorization, checking whether the specific authenticated user is allowed to do the specific thing they're requesting, that's always a check you write yourself, regardless of which one you're using.
If you have API route handlers in production that mutate data based on a session cookie, worth checking right now whether they verify the request's origin, or whether they'd happily process a request from anywhere as long as a valid session cookie rode along. Drop what you find in the comments.
Get the templates: https://pixelanas.gumroad.com
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)