---
title: "Payload CMS Auth in Next.js App Router — Fix CSRF Now"
slug: "payload-cms-auth-nextjs-app-router"
published: "2026-05-19"
updated: "2026-05-28"
validated: "2026-05-28"
categories:
  - "Next.js"
tags:
  - "Payload CMS auth"
  - "Next.js App Router auth"
  - "strip Origin header"
  - "buildAuthHeaders"
  - "payload-token cookie"
  - "server actions Next.js"
  - "@payloadcms/next/auth"
  - "requireAuthenticatedPlayer"
  - "performLogout"
  - "Payload Local API"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload cms"
  - "next.js app router"
  - "@payloadcms/next/auth"
  - "react.cache"
  - "typescript"
status: "stable"
llm-purpose: "Payload CMS auth: fix session failures in Next.js App Router by stripping Origin headers and using buildAuthHeaders. Read the step-by-step guide and…"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to Next.js App Router"
  - "Access to @payloadcms/next/auth"
  - "Access to React.cache"
  - "Access to TypeScript"
llm-outputs:
  - "Completed outcome: Payload CMS auth: fix session failures in Next.js App Router by stripping Origin headers and using buildAuthHeaders. Read the step-by-step guide and…"
---

**Summary Triples**
- (Payload cookie auth, rejects session when Origin header mismatches CSRF config, Payload checks payload.config.csrf and returns user:null if Origin is present and mismatched)
- (Next.js App Router server actions (POST), include Origin header, causes authenticated actions to fail in Payload unless Origin is removed)
- (Fix, strip Origin header before payload.auth(), ensures JWT cookie (payload-token) is validated and session is read correctly)
- (buildAuthHeaders utility, merges cookies into request headers and deletes Origin, use it for every server-side Payload call (RSC reads, layout gating, server actions))
- (Logout/performLogout, must be routed server-side with merged cookies and no Origin, prevents CSRF-related logout failures)
- (Application pattern, centralize auth header building, prevents scattered Origin-removal bugs and ensures consistent Payload behavior)

### {GOAL}
Payload CMS auth: fix session failures in Next.js App Router by stripping Origin headers and using buildAuthHeaders. Read the step-by-step guide and…

### {PREREQS}
- Access to Payload CMS
- Access to Next.js App Router
- Access to @payloadcms/next/auth
- Access to React.cache
- Access to TypeScript

### {STEPS}
1. Create buildAuthHeaders utility
2. Add cached session reader
3. Implement layout redirect gate
4. Make action-specific auth helpers
5. Route login through server action
6. Implement performLogout safely
7. Use Local API with user context
8. Test and verify in DevTools

<!-- llm:goal="Payload CMS auth: fix session failures in Next.js App Router by stripping Origin headers and using buildAuthHeaders. Read the step-by-step guide and…" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to Next.js App Router" -->
<!-- llm:prereq="Access to @payloadcms/next/auth" -->
<!-- llm:prereq="Access to React.cache" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:output="Completed outcome: Payload CMS auth: fix session failures in Next.js App Router by stripping Origin headers and using buildAuthHeaders. Read the step-by-step guide and…" -->

# Payload CMS Auth in Next.js App Router — Fix CSRF Now
> Payload CMS auth: fix session failures in Next.js App Router by stripping Origin headers and using buildAuthHeaders. Read the step-by-step guide and…
Matija Žiberna · 2026-05-19

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:

| File | Purpose |
|------|---------|
| `src/utilities/auth/buildAuthHeaders.ts` | Origin strip and cookie merge |
| `src/utilities/auth/getAuthenticated<Collection>.ts` | Nullable session read, cached |
| `src/utilities/auth/requireAuthenticated<Collection>.ts` | Redirect gate |
| `src/utilities/auth/requireAuthenticated<Collection>ForAction.ts` | Uncached gate for server actions |
| `src/app/.../auth-actions.ts` | `loginPlayer` and `performLogout` |
| `src/app/.../(protected)/layout.tsx` | Single 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

## LLM Response Snippet
```json
{
  "goal": "Payload CMS auth: fix session failures in Next.js App Router by stripping Origin headers and using buildAuthHeaders. Read the step-by-step guide and…",
  "responses": [
    {
      "question": "What does the article \"Payload CMS Auth in Next.js App Router — Fix CSRF Now\" cover?",
      "answer": "Payload CMS auth: fix session failures in Next.js App Router by stripping Origin headers and using buildAuthHeaders. Read the step-by-step guide and…"
    }
  ]
}
```