BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. 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…

19th May 2026·Updated on:28th May 2026··
Next.js
Payload CMS Auth in Next.js App Router — Fix CSRF Now

⚡ 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.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

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.

Contents

  • Why GET Works and POST Breaks
  • `buildAuthHeaders` — The Foundation
  • Two Kinds of Auth Helpers
  • The Layout Gate Pattern
  • Login via Server Action
  • Logout — Don't Use the Stock Helper
  • Using the Local API With Auth
  • Handling Two Auth Collections
  • What to Build
  • Wrapping Up
On this page:
  • Why GET Works and POST Breaks
  • `buildAuthHeaders` — The Foundation
  • Two Kinds of Auth Helpers
  • The Layout Gate Pattern
  • Login via Server Action
Build with Matija logo

Build with Matija

Modern websites, content systems, and AI workflows built for long-term growth.

Services

  • Headless CMS Websites
  • Next.js & Headless CMS Advisory
  • AI Systems & Automation
  • Website & Content Audit

Resources

  • Case Studies
  • How I Work
  • Blog
  • Topics
  • CMS Hub
  • E-commerce Hub
  • B2B Website Strategy
  • Dashboard

Headless CMS

  • Payload CMS Developer
  • CMS Migration
  • Multi-Tenant CMS
  • Payload vs Sanity
  • Payload vs WordPress
  • Payload vs Contentful

Get in Touch

Ready to modernize your stack? Let's talk about what you're building.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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 payload-token HTTP-only cookie and verifies the JWT on every request. The wrinkle is CSRF: when Payload sees an Origin header on the incoming request, it checks that origin against payload.config.csrf. If it doesn't match — no token, user: null, 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.

ts
// File: src/utilities/auth/buildAuthHeaders.ts
import { cookies as nextCookies, headers as nextHeaders } from 'next/headers';

export async function buildAuthHeaders(requestHeaders?: HeadersInit): Promise<Headers> {
  const headers = requestHeaders
    ? new Headers(requestHeaders)
    : new Headers(await nextHeaders());

  const cookieStore = await nextCookies();
  const cookieHeader = cookieStore
    .getAll()
    .map((c) => `${c.name}=${encodeURIComponent(c.value)}`)
    .join('; ');

  if (cookieHeader) headers.set('cookie', cookieHeader);
  headers.delete('origin');

  return headers;
}

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.

ts
// File: src/utilities/auth/getAuthenticatedPlayer.ts
import { cache } from 'react';
import { getPayload } from 'payload';
import config from '@payload-config';
import { buildAuthHeaders } from './buildAuthHeaders';

export const getAuthenticatedPlayer = cache(async () => {
  const payload = await getPayload({ config });
  const headers = await buildAuthHeaders();
  const { user } = await payload.auth({ headers });

  if (!user || user.collection !== 'players') return null;
  return user;
});
ts
// File: src/utilities/auth/requireAuthenticatedPlayer.ts
import { redirect } from 'next/navigation';
import { getAuthenticatedPlayer } from './getAuthenticatedPlayer';

export async function requireAuthenticatedPlayer(redirectTo = '/login') {
  const user = await getAuthenticatedPlayer();
  if (!user) redirect(redirectTo);
  return user;
}

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:

ts
// File: src/utilities/auth/requireAuthenticatedPlayerForAction.ts
import { getPayload } from 'payload';
import { redirect } from 'next/navigation';
import config from '@payload-config';
import { buildAuthHeaders } from './buildAuthHeaders';

export async function requireAuthenticatedPlayerForAction() {
  const payload = await getPayload({ config });
  const headers = await buildAuthHeaders();
  const { user } = await payload.auth({ headers });

  if (!user || user.collection !== 'players') redirect('/login');
  return user;
}

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.

text
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
ts
// File: src/app/dashboard/(protected)/layout.tsx
import { requireAuthenticatedPlayer } from '@/utilities/auth/requireAuthenticatedPlayer';

export default async function ProtectedLayout({ children }: { children: React.ReactNode }) {
  await requireAuthenticatedPlayer();
  return <>{children}</>;
}

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:

ts
// File: src/app/dashboard/login/auth-actions.ts
'use server';

import { login } from '@payloadcms/next/auth';
import config from '@payload-config';

export async function loginPlayer(input: { email: string; password: string; redirect?: string }) {
  const result = await login({
    collection: 'players',
    config,
    email: input.email,
    password: input.password,
  });

  if (!result.token) {
    return { ok: false, message: 'Invalid credentials' };
  }

  return { ok: true, redirectTo: input.redirect ?? '/dashboard' };
}

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:

ts
// File: src/app/dashboard/login/auth-actions.ts (continued)
'use server';

import { getPayload } from 'payload';
import { cookies as nextCookies, headers as nextHeaders } from 'next/headers';
import { createLocalReq } from 'payload';
import { logoutOperation } from 'payload/operations';
import config from '@payload-config';

export async function performLogout(): Promise<void> {
  const payload = await getPayload({ config });

  const headers = new Headers(await nextHeaders());
  headers.delete('origin');

  const { user } = await payload.auth({ headers });

  if (user) {
    const req = await createLocalReq({ user }, payload);
    const collection = payload.collections[user.collection];
    await logoutOperation({ allSessions: false, collection, req });
  }

  const cookieStore = await nextCookies();
  cookieStore.delete(`${payload.config.cookiePrefix}-token`);
}

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:

ts
// File: src/app/dashboard/(protected)/orders/page.tsx
import { getPayload } from 'payload';
import config from '@payload-config';
import { requireAuthenticatedPlayer } from '@/utilities/auth/requireAuthenticatedPlayer';

export default async function OrdersPage() {
  const player = await requireAuthenticatedPlayer();
  const payload = await getPayload({ config });

  const orders = await payload.find({
    collection: 'orders',
    where: { player: { equals: player.id } },
    user: player,
    overrideAccess: false,
  });

  return <OrderList orders={orders.docs} />;
}

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:

ts
// File: src/app/layout.tsx
import { getAuthenticatedPlayer } from '@/utilities/auth/getAuthenticatedPlayer';
import { getAuthenticatedUser } from '@/utilities/auth/getAuthenticatedUser';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const player = await getAuthenticatedPlayer();
  const staffUser = await getAuthenticatedUser();

  return (
    <html>
      <body>
        <Nav player={player} staffUser={staffUser} />
        {children}
      </body>
    </html>
  );
}

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:

FilePurpose
src/utilities/auth/buildAuthHeaders.tsOrigin strip and cookie merge
src/utilities/auth/getAuthenticated<Collection>.tsNullable session read, cached
src/utilities/auth/requireAuthenticated<Collection>.tsRedirect gate
src/utilities/auth/requireAuthenticated<Collection>ForAction.tsUncached gate for server actions
src/app/.../auth-actions.tsloginPlayer and performLogout
src/app/.../(protected)/layout.tsxSingle route gate

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