Next.js CSRF Protection: 17 Actions Covered, 4 Routes On Their Own
Server Actions get automatic Origin-header CSRF checks. This codebase's 4 Route Handlers don't — a census of what actually protects each one instead.
Next.js gives every Server Action free CSRF protection: it compares the request's Origin header against the Host it was sent to, and rejects a mismatch before your code runs. That covers the classic cross-site form post automatically — but only for Server Actions. This codebase has 17 of those across 8 files, all covered for free, and 4 Route Handlers that get none of it, because a Route Handler is a plain HTTP endpoint with no framework opinion about who's allowed to call it. Each of the four needed a different answer.
What the free protection actually covers
src/lib/actions/ holds 17 exported Server Actions across auth.ts, account.ts, checkout.ts, contact.ts, entitlementsView.ts, newsletter.ts, redemption.ts and refund.ts — everything from signInWithPassword to redeemSlot. Every one of them is invoked as a form action or from useActionState, and every one gets the same Origin-vs-Host check before the function body runs. The Server Actions vs. API routes FAQ already states the mechanism precisely: it "covers the classic cross-site form post," and it's "not a substitute for the session-derived ownership check" — redeemSlot's atomic, user_id-scoped update is that ownership check, not the Origin comparison. CSRF protection answers "did this request originate from our own page," not "is this user allowed to do this" — those are different questions, and Server Actions only answer the first one for free.
Nothing about the check depends on the visitor being signed in, either. sendContactMessage — the contact form's Server Action — needs no session at all, just an email and a message, plus a honeypot field for spam. It still gets the same Origin/Host comparison as redeemSlot. That matters because CSRF protection and authentication are answering two different questions even for an anonymous form: the Origin check stops another site from silently auto-submitting your contact form on a visitor's behalf, which has nothing to do with whether that visitor is logged in.
The four Route Handlers, and what actually protects each
None of /api/download, /api/webhooks/lemonsqueezy, /auth/callback or /blog/feed.xml is a Server Action, so none gets the automatic Origin check. Each has to answer the CSRF question on its own terms — and the honest answer is different for all four:
| Route | Method | What protects it | Why |
|---|---|---|---|
/api/webhooks/lemonsqueezy | POST | HMAC-SHA256 signature verification, not the browser at all | LemonSqueezy calls this server-to-server with a shared secret — no cookie is ever sent, so there's no ambient credential for a forged browser request to ride along on. CSRF doesn't apply to a request that never trusted the browser's session in the first place. |
/api/download | GET | Session cookie, checked by getClaims() | Genuinely CSRF-relevant, and the interesting case below. |
/auth/callback | GET | A single-use PKCE code in the query string | The code itself is the credential, not the cookie — an attacker without the buyer's own code can't replay this request meaningfully, because the code is spent on first use regardless of who presents it. |
/blog/feed.xml | GET | Nothing — it's public | No session, no mutation, nothing to forge. |
The webhook and the feed are easy: one authenticates a machine with a secret the browser never sees, the other authenticates nobody because there's nothing to protect. The callback route's real risk category is different from classic CSRF — a forced login rather than a forced action — and its one-time code closes that door on its own. /api/download is the one worth slowing down on.
The GET request that mutates state
/api/download reads a session cookie and, on success, calls record_download_within_limit() — the same atomic RPC covered in gated file downloads — which writes a download event and consumes a slot of the caller's hourly rate limit. That's a real side effect, sitting behind a GET, authenticated only by a cookie:
// src/app/api/download/route.ts
export async function GET(req: NextRequest) {
// … reads the session cookie, resolves productSlug/framework from
// searchParams, and on success records a download + consumes rate limit
}
This is exactly the shape classic CSRF targets, and it's worth being precise about what "vulnerable" would mean here. This codebase doesn't set an explicit SameSite value on the Supabase auth cookie (grepping the whole src/ tree for sameSite turns up nothing), so it inherits the library's default of Lax. Lax is designed to still let a cross-site link into a logged-in page work — click a link from another site into this one, and your session cookie rides along on that top-level navigation — while blocking the cookie on embedded cross-site requests like an <img src> or a background fetch. That's precisely the gap: a GET endpoint with a side effect is reachable by exactly the request Lax was designed to still allow through.
The practical blast radius is narrow — an attacker can't read the response (the browser's Same-Origin Policy blocks that regardless of cookies), so the only thing a forced request accomplishes is spending one of the victim's own rate-limited downloads and adding a row to their own download history. It's an annoyance, not a data leak. But it's a real instance of the general rule this pattern exists to teach: CSRF protection is a property of the request being state-changing, not of it looking like a "write." A GET that only reads is safe by construction; a GET that writes inherits every risk a POST has, minus the one mitigation (Origin checking on Server Actions, or a same-site-strict cookie) that's normally built around the assumption that mutations arrive as POST.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Assuming a Route Handler gets the same Origin check as a Server Action | It doesn't — Route Handlers are plain HTTP, no framework CSRF logic applies | Verify the caller explicitly: a signature, a session-derived ownership check, or your own Origin comparison |
Putting a state-changing operation behind GET | Reachable via a plain link or redirect, no form submission required | Use POST/PUT/DELETE for anything that writes, and treat any exception (like a download-and-log endpoint) as a deliberate, documented trade-off |
| Verifying a webhook signature only when a secret env var happens to be set | A misconfigured deploy silently accepts forged requests | Fail closed — no secret configured should mean the route refuses everything, not that it skips the check |
Trusting Origin header presence rather than its value | Some legitimate same-site requests omit Origin, and an attacker can omit it too on some request types | Compare the header's value against your own host when present; don't treat "header missing" as automatically safe |
| Building custom CSRF tokens for Server Actions | Redundant — Next.js already does the Origin/Host check before your code runs | Spend the effort on the authorization check instead (who is this user allowed to act as), which no CSRF mechanism provides |
| Assuming CSRF and session hijacking are the same threat | Different fixes get applied to the wrong problem | CSRF is about an unwanted request from a legitimate, logged-in browser; session hijacking is about an attacker holding the session itself — getClaims() vs. getSession() is that second problem |
Frequently asked questions
Do Server Actions need CSRF tokens?
No. Next.js compares the Origin header against the Host on every Server Action invocation and rejects a mismatch automatically — no token to generate, store, or validate. What it doesn't replace is an authorization check: the Origin comparison proves the request came from your own page, not that the signed-in user is allowed to do what they're asking.
Is a webhook endpoint vulnerable to CSRF? Not in the traditional sense, if it's authenticated the right way. This codebase's LemonSqueezy webhook trusts an HMAC signature over the raw request body, computed with a secret only LemonSqueezy and this server know — a forged browser request has no way to produce a valid signature, so there's no ambient credential (a cookie) for CSRF to exploit in the first place.
Why doesn't /auth/callback need explicit CSRF protection?
Because its credential is the one-time PKCE code in the URL, not a cookie the browser sends automatically. An attacker who doesn't have a specific victim's code can't replay this request meaningfully — the code is single-use, so even seeing one used once doesn't help with the next request.
Should every GET route in a Next.js app avoid side effects?
That's the safer default, and it's what makes CSRF a non-issue for most GET endpoints by construction — there's nothing to gain by forging a request that only reads. When a GET does have to record something (this codebase's download-and-rate-limit case), the honest move is deciding what the worst case actually costs, as this one does, rather than assuming GET is automatically safe.
Templates where this pattern already ships
ASoc Clover Admin is a CRM-focused admin dashboard with its own auth screens and form-heavy Sales, Finance and Team Management views — exactly the density of mutating actions where getting this right matters. ASoc Crest Admin and ASoc Estate Admin round out the set with their own full auth flows and app-module forms.
Browse the full sets: React admin templates, Next.js admin templates and Tailwind admin templates. For the session-verification half of this same request path, read Auth in React: the session belongs in a cookie; for the download route's full authorization ordering, gated file downloads.
