Payload CMS Auth in Next.js App Router — Fix CSRF Now
Payload CMS Auth in Next.js App Router — Fix CSRF Now
How to strip Origin headers, merge cookies, and implement buildAuthHeaders for reliable Payload CMS auth in Next.js…
·Updated on:··
⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
I was building a storefront with Payload CMS 3.85 and Next.js App Router when I ran into a maddening bug: the user appeared logged in on every page load, but the moment they submitted a form, the app redirected them back to login. Same cookie. Same session. Different result depending on whether it was a GET or a POST.
After digging into Payload's JWT cookie strategy, I found the culprit — the Origin header — and a set of patterns that fix it cleanly. This guide walks through the full auth setup: how to read the session server-side, how to gate routes with a single layout, and how to log out without fighting CSRF.
Why GET Works and POST Breaks
Payload's cookie auth reads the HTTP-only cookie and verifies the JWT on every request. The wrinkle is CSRF: when Payload sees an header on the incoming request, it checks that origin against . If it doesn't match — no token, , even if the cookie is perfectly valid.
GET requests from the browser typically don't send an Origin header, so auth works. Server actions in Next.js App Router are POST requests, and Next.js includes Origin. That's the asymmetry. Everything looks fine until you try to actually do something authenticated.
The fix is straightforward: strip the Origin header before every payload.auth() call. But doing it consistently across RSC reads, layout gates, and server actions requires a shared helper rather than inline fixes scattered across files.
buildAuthHeaders — The Foundation
Every authenticated server-side call in the app runs through this one utility. It pulls cookies from the Next.js cookie store, merges them into the headers, and deletes Origin.
The cookie merge matters for server actions specifically. When a server action runs, the headers() object from Next.js may not always include the full cookie string. Explicitly pulling from cookies() and setting it on the headers makes session reads reliable in both RSC and action contexts. The Origin delete is always safe — Payload's CSRF check is browser-facing protection, and server-to-server calls don't need it.
Two Kinds of Auth Helpers
With buildAuthHeaders in place, you build two layers of helpers on top of it: one for optional reads, one for hard gates.
getAuthenticatedPlayer returns null if there's no session or if the JWT belongs to a different collection — say, a staff users session. It's safe to call from nav components or any layout that wants to conditionally render chrome. The React.cache() wrapper means payload.auth() only runs once per request even if multiple components call it.
requireAuthenticatedPlayer is the redirect gate. It calls the cached read and redirects if the session is missing. This is what goes in layout files, not in individual pages.
For server actions, never use the cached version. Create a separate helper that builds fresh headers on every call:
React.cache() is request-scoped. Server actions run in a separate execution context from the RSC render, so a cached result from the page render can't be trusted inside the action. Always build fresh.
The Layout Gate Pattern
Route protection should live in one place: a (protected) route group layout. Not on individual pages.
src/app/
dashboard/
layout.tsx ← shell only, no auth
login/
page.tsx ← inverse gate: redirect if already logged in
(protected)/
layout.tsx ← requireAuthenticatedPlayer here, nowhere else
profile/
page.tsx
orders/
page.tsx
The outer dashboard/layout.tsx renders the shell — header, nav, sidebar — without any auth check. The inner (protected)/layout.tsx is purely a gate. Pages inside that group can call requireAuthenticatedPlayer() again to get the user object, or use a thin wrapper like getDashboardUser() that does the same thing. Both are fine because the redirect is idempotent.
What you want to avoid is getAuthenticatedPlayer() on a dashboard page followed by if (!user) return null. If the layout and the page render concurrently — which Next.js App Router does — the page can return an error state before the layout's redirect fires. You get HTTP 200 with broken UI instead of a clean redirect. Use requireAuthenticatedPlayer() on the page too, or wrap it in a getDashboardUser() helper that redirects rather than returning null.
Login via Server Action
Route auth forms through server actions, not direct browser requests to the Payload REST API. The @payloadcms/next/auth package handles the cookie:
login() sets the payload-token cookie automatically. The browser never touches /api/players/login directly, which means you don't need to maintain CSRF allowlists for every origin.
Logout — Don't Use the Stock Helper
The logout() export from @payloadcms/next/auth has the same CSRF problem as any other server-side Payload call: if Origin is present, session revocation can fail and the cookie doesn't get cleared. Build performLogout yourself:
The critical line is the cookie delete at the end. Always delete it, even if session revocation fails. The cookie is the session handle — clear it and the user is logged out from the browser's perspective regardless of what happened on the server. After calling performLogout, verify the cookie is gone in DevTools → Application → Cookies before considering logout done.
Using the Local API With Auth
When reading user-owned data through the Payload Local API, pass the user object and set overrideAccess: false. This makes Payload enforce whatever access rules you defined on the collection:
One gotcha: JWT payloads return IDs as strings. If your collection uses numeric IDs, coerce with Number(player.id) before using it in a where clause. The type looks fine but the query silently returns nothing.
Handling Two Auth Collections
If your app has both a storefront collection (players) and a staff collection (users), the same payload-token cookie can hold either JWT — the encoded collection field tells them apart. Public layouts that conditionally render nav or chrome need to branch on that field, not just check whether a user exists:
A truthy user alone isn't enough. An organizer logged into the staff area holds a valid users JWT — showing them player profile chrome is incorrect, and vice versa. Keep the collection check explicit.
What to Build
To implement this pattern from scratch in a new repo, you need these files:
None of these are large files — most are under 20 lines. The architecture pays off once you add more routes, because auth lives in exactly two places: buildAuthHeaders and the (protected) layout.
Wrapping Up
The CSRF issue in Payload auth isn't a bug — it's the CSRF check doing its job. The solution is to route all auth through server actions and strip Origin before any server-side payload.auth() call. The buildAuthHeaders helper makes that consistent, the layout gate keeps route protection in one place, and performLogout handles cookie cleanup reliably.
Once this foundation is in place, adding new protected routes is a matter of putting them inside (protected)/ — no per-page auth code needed.
Let me know in the comments if you run into edge cases, and subscribe for more practical Next.js and Payload CMS guides.
Thanks,
Matija
I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.