---
title: "Payload CMS Authentication: Secure Next.js Pages — Proven"
slug: "payload-cms-auth-nextjs-dashboard"
published: "2026-04-23"
updated: "2026-05-02"
validated: "2026-05-02"
categories:
  - "Payload"
tags:
  - "Payload CMS authentication"
  - "Next.js payload auth"
  - "payload.auth headers"
  - "HTTP-only cookie auth"
  - "CSRF protection payload"
  - "payload.login server action"
  - "cookie-based auth Next.js"
  - "protected route handlers"
  - "sameSite secure cookies"
  - "payload cookiePrefix"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "next.js@15"
  - "payload@2"
  - "node@20"
  - "react@18"
status: "stable"
llm-purpose: "Payload CMS authentication: pass real request headers to payload.auth() in Next.js to prevent CSRF cookie failures, stop redirect loops, and secure…"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to Next.js"
  - "Access to React"
  - "Access to Node.js"
  - "Access to HTTP cookies"
llm-outputs:
  - "Completed outcome: Payload CMS authentication: pass real request headers to payload.auth() in Next.js to prevent CSRF cookie failures, stop redirect loops, and secure…"
---

**Summary Triples**
- (Payload cookie auth, uses, HTTP-only cookies for same-site requests, set by payload.login)
- (Server-side payload.auth(), must receive, the real incoming request headers so cookie and CSRF can be validated)
- (next/headers, provides, the incoming request headers inside Next.js server components)
- (Omitting real headers, causes, Payload to resolve user as null and can produce redirect loops)
- (Typical protection pattern, is, call payload.auth() on the server and redirect to /login if user is null)
- (Proxies (Nginx/Cloudflare), must, forward cookies and original headers (Host, Origin, Referer, X-Forwarded-*) to preserve auth and CSRF behavior)
- (Cookie settings, should be consistent, cookiePrefix, SameSite, secure, and domain across login and auth checks)
- (Testing, requires, verifying Set-Cookie on login and Cookie header on subsequent server requests (curl/Playwright))
- (Fixing redirect loops, involves, passing headers(), ensuring proxy forwards headers, and aligning cookie options)

### {GOAL}
Payload CMS authentication: pass real request headers to payload.auth() in Next.js to prevent CSRF cookie failures, stop redirect loops, and secure…

### {PREREQS}
- Access to Payload CMS
- Access to Next.js
- Access to React
- Access to Node.js
- Access to HTTP cookies

### {STEPS}
1. Understand Payload cookie strategy
2. Protect pages with payload.auth
3. Avoid rebuilding Headers from cookies
4. Implement login server action
5. Protect API route handlers
6. Verify on localhost and production
7. Align CSRF, CORS, and cookie settings

<!-- llm:goal="Payload CMS authentication: pass real request headers to payload.auth() in Next.js to prevent CSRF cookie failures, stop redirect loops, and secure…" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to Node.js" -->
<!-- llm:prereq="Access to HTTP cookies" -->
<!-- llm:output="Completed outcome: Payload CMS authentication: pass real request headers to payload.auth() in Next.js to prevent CSRF cookie failures, stop redirect loops, and secure…" -->

# Payload CMS Authentication: Secure Next.js Pages — Proven
> Payload CMS authentication: pass real request headers to payload.auth() in Next.js to prevent CSRF cookie failures, stop redirect loops, and secure…
Matija Žiberna · 2026-04-23

# How to Authenticate a Custom Page with Payload CMS Auth

If you are building a custom page like `/dashboard` or `/account` inside a Payload CMS + Next.js app, the right move is usually to use Payload's built-in authentication instead of introducing a second auth system.

That sounds straightforward, but there is one detail that matters a lot:

**When you authenticate server-rendered pages with Payload cookie auth, you need to pass the real incoming request headers to `payload.auth()`.**

I am writing this because I hit the failure mode myself while protecting a custom `/dashboard` route. The login succeeded, the browser clearly had the HTTP-only cookie, and yet the server kept resolving `user: null` and redirecting `/dashboard` back to `/login`. The root cause was not the cookie itself. It was how the auth check was being performed on the server.

This guide shows the pattern that actually works, the mistakes that cause redirect loops, and how to verify your setup properly.

---

## What Payload Is Actually Using

For most custom pages inside the same app, you will be using Payload's **HTTP-only cookie** strategy.

That means:

- the browser stores the auth token in an HTTP-only cookie
- JavaScript in the browser cannot read it
- the browser sends it automatically on same-site requests
- your server-side Next.js page can ask Payload to resolve the current user from that cookie

This is a good default because it is secure, already integrated into Payload, and works naturally for custom pages inside the same Next.js app.

---

## The Protected Page Pattern

On a protected page, call `payload.auth()` on the server and redirect if there is no user.

```tsx
// src/app/(frontend)/(non-intl)/dashboard/page.tsx
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { getPayload } from "payload";
import configPromise from "@payload-config";

export default async function DashboardPage() {
  const payload = await getPayload({ config: configPromise });
  const { user } = await payload.auth({ headers: await headers() });

  if (!user) {
    redirect("/login");
  }

  return <div>Signed in as {user.email}</div>;
}
```

This is the key line:

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

Do not replace that with a manually reconstructed `Headers` object unless you know exactly why you are doing it.

---

## Why Manual Cookie-Only Headers Break

This looks innocent:

```ts
const cookieStore = await cookies();
const cookieHeader = cookieStore
  .getAll()
  .map((cookie) => `${cookie.name}=${cookie.value}`)
  .join("; ");

const headers = new Headers();
headers.set("cookie", cookieHeader);

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

But this can break authentication when Payload's `csrf` protection is enabled.

Payload does not only inspect the auth cookie. It also looks at request context like:

- `Origin`
- `Sec-Fetch-Site`

If you strip those away and pass only `cookie`, Payload may reject cookie auth and return `user: null`.

That was the exact production-style bug I hit:

- login succeeded
- the browser had the cookie
- `/dashboard` still redirected back to `/login`

The fix was to pass the real incoming headers through unchanged.

---

## The Login Page Pattern

Your login page should do the inverse: if the user is already signed in, redirect them away from `/login`.

```tsx
// src/app/(frontend)/(non-intl)/login/page.tsx
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { getPayload } from "payload";
import configPromise from "@payload-config";

import { LoginForm } from "@/components/auth/LoginForm";

export default async function LoginPage() {
  const payload = await getPayload({ config: configPromise });
  const { user } = await payload.auth({ headers: await headers() });

  if (user) {
    redirect("/dashboard");
  }

  return <LoginForm />;
}
```

This keeps the auth flow symmetric:

- `/dashboard` sends unauthenticated users to `/login`
- `/login` sends authenticated users to `/dashboard`

---

## A Server Action for Login

If you want a custom login form, use a server action that calls `payload.login()` and then writes the Payload auth cookie.

```ts
"use server";

import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getPayload } from "payload";
import configPromise from "@payload-config";

export async function loginAction(_: unknown, formData: FormData) {
  const payload = await getPayload({ config: configPromise });

  const result = await payload.login({
    collection: "users",
    data: {
      email: String(formData.get("email") || ""),
      password: String(formData.get("password") || ""),
    },
  });

  if (!result.token) {
    return { error: "Invalid email or password." };
  }

  const usersCollection = payload.config.collections.find(
    (collection) => collection.slug === "users"
  );

  if (!usersCollection?.auth) {
    return { error: "User authentication is not configured." };
  }

  const tokenExpiration = usersCollection.auth.tokenExpiration;
  const cookieStore = await cookies();

  cookieStore.set({
    name: `${payload.config.cookiePrefix}-token`,
    value: result.token,
    path: "/",
    httpOnly: true,
    maxAge: tokenExpiration,
    expires: new Date(Date.now() + tokenExpiration * 1000),
    sameSite: "lax",
    secure: false,
  });

  redirect("/dashboard");
}
```

This gives you:

- a custom form UI
- Payload's existing user collection and login logic
- an HTTP-only cookie the server can use on future requests

For production, make sure cookie settings match your deployment setup.

---

## Authenticated Route Handlers

The same rule applies to route handlers. Pass the real request headers through to Payload.

```ts
// app/api/some-protected-route/route.ts
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import configPromise from "@payload-config";

export async function POST(request: Request) {
  const payload = await getPayload({ config: configPromise });
  const { user } = await payload.auth({ headers: request.headers });

  if (!user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  return NextResponse.json({ userId: user.id });
}
```

This is the right pattern for:

- dashboard AJAX routes
- OAuth approval endpoints
- token refresh endpoints
- any custom API route that needs the current Payload user

---

## Biggest Gotchas

These are the mistakes most likely to waste your time.

### 1. Rebuilding `Headers` from `cookies()` only

This is the biggest one.

If you build a new `Headers` object and only set `cookie`, you may accidentally remove the request metadata Payload uses to validate cookie auth under CSRF protection.

That is how you end up with:

- a valid browser cookie
- a failed server auth check
- a redirect loop that looks irrational until you inspect Payload's auth extraction logic

### 2. Assuming "cookie exists" means "Payload will authenticate"

Those are not the same thing.

A browser can absolutely have the correct auth cookie while your server code still fails to authenticate the request. The cookie is only one part of the decision. Request context matters too.

### 3. Forgetting to align `csrf` and `cors` with your actual frontend domains

If your Payload config does not trust the domains your frontend is coming from, cookie auth will break or behave inconsistently.

Your config should explicitly include the real domains you use:

```ts
serverURL: process.env.NEXT_PUBLIC_URL || "",
csrf: [
  process.env.NEXT_PUBLIC_URL || "",
  "http://localhost:3000",
  "https://www.your-domain.com",
].filter(Boolean),
cors: [
  process.env.NEXT_PUBLIC_URL || "",
  "http://localhost:3000",
  "https://www.your-domain.com",
].filter(Boolean),
```

### 4. Mixing a custom login flow with Payload defaults without checking your collection auth config

If you are manually setting cookies after `payload.login()`, check the collection auth settings carefully.

In particular:

- token expiration
- cookie naming via `cookiePrefix`
- `sameSite`
- `secure`
- domain settings

If those do not match the environment you are actually running in, login may appear to work while subsequent requests fail.

### 5. Underestimating cross-domain setups

If your frontend and Payload API are on different domains, cookie auth gets much trickier because the browser now treats those as third-party cookie scenarios.

If possible:

- keep frontend and Payload on the same site
- or use subdomains of the same parent domain

If you cannot do that, then you need to think carefully about:

- `sameSite: "none"`
- `secure: true`
- HTTPS everywhere
- explicit CORS allowlists

### 6. Using old form abstractions and new form abstractions interchangeably

This is not specific to Payload auth, but it shows up fast on custom login pages.

If your UI kit has both:

- older provider-based form wrappers
- newer field primitives

do not mix them casually. In this codebase, using the old `ui/form` helpers outside a `react-hook-form` provider caused a runtime crash on `/login`.

---

## How to Verify It Actually Works

Do not stop at "login form submits successfully." Verify the full lifecycle.

### 1. Check that the browser has the auth cookie

Open browser developer tools and inspect the cookies for your domain.

You should see the Payload auth cookie, usually named like:

```txt
<cookiePrefix>-token
```

It should be HTTP-only.

### 2. Verify `/login` redirects away when already authenticated

Once signed in:

- open `/login`
- confirm it redirects to `/dashboard`

If it still renders the login form, your server-side auth resolution is failing.

### 3. Verify `/dashboard` redirects to `/login` when signed out

Clear the auth cookie or sign out, then open `/dashboard`.

It should redirect to `/login`.

If it renders anyway, your protected page is not actually protected.

### 4. Verify a protected route handler resolves `user`

Call one of your authenticated route handlers and confirm it returns a valid user-dependent response instead of `401 Unauthorized`.

This matters because page auth and API auth often diverge when people implement them with slightly different header handling.

### 5. Verify on localhost and production separately

Cookie auth issues often differ between:

- `http://localhost:3000`
- your production HTTPS domain

Local success does not automatically guarantee production success if cookie settings differ.

---

## Deployment Caveats

This is where many "works locally, fails in production" auth bugs come from.

### Local development

On localhost, `secure: true` will usually break cookie usage because the app is not being served over HTTPS.

For local development, keep this false unless you have local HTTPS configured.

### Production

In production:

- use HTTPS
- audit `secure`
- audit `sameSite`
- confirm your public URL matches `serverURL`

### Reverse proxies and CDNs

If you deploy behind Nginx, Cloudflare, or another proxy, be aware that request headers may be modified or forwarded differently. If auth starts behaving strangely only after deployment, inspect the actual incoming headers seen by your Next.js app.

### Cross-domain frontends

If your frontend is on a separate domain from the Payload API, cookie auth is no longer a simple default. At that point you need to make an explicit architecture decision rather than assuming same-site cookie behavior will just carry over.

---

## The Main Lesson

The subtle part is not logging users in. Payload already does that well.

The subtle part is **reading** the authenticated user correctly on the server.

The rule I would follow every time:

- on server pages, use `await headers()` and pass that directly to `payload.auth()`
- on route handlers, use `request.headers` or `req.headers`
- do not rebuild `Headers` from `cookies()` unless you are intentionally reproducing the full request context

That one detail is the difference between:

- a clean custom `/dashboard` auth flow

and:

- a browser that clearly has the cookie
- a server that still says the user is anonymous
- a redirect loop that makes no sense until you read Payload's auth extraction logic closely

---

## Minimal Checklist

If you want the short operational version:

- protect your custom page with `payload.auth({ headers: await headers() })`
- redirect unauthenticated users to `/login`
- redirect authenticated users away from `/login`
- use `payload.login()` in a server action for your custom login form
- set the Payload auth cookie after login
- pass real incoming headers to `payload.auth()` in route handlers
- keep `csrf` and `cors` aligned with your real frontend domains
- verify the flow on both localhost and production

If you get those right, custom page auth with Payload is simple and stable.

## LLM Response Snippet
```json
{
  "goal": "Payload CMS authentication: pass real request headers to payload.auth() in Next.js to prevent CSRF cookie failures, stop redirect loops, and secure…",
  "responses": [
    {
      "question": "What does the article \"Payload CMS Authentication: Secure Next.js Pages — Proven\" cover?",
      "answer": "Payload CMS authentication: pass real request headers to payload.auth() in Next.js to prevent CSRF cookie failures, stop redirect loops, and secure…"
    }
  ]
}
```