BuildWithMatija
Get In Touch
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
  • CMS Hub
  • E-commerce Hub
  • Dashboard

Headless CMS

  • Payload CMS Developer
  • CMS Migration
  • 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
  1. Home
  2. Blog
  3. Payload
  4. Payload CMS Authentication: Secure Next.js Pages — Proven

Payload CMS Authentication: Secure Next.js Pages — Proven

Fix redirect loops and secure cookie auth by passing real request headers to payload.auth(); handle CSRF and protect…

23rd April 2026·Updated on:2nd May 2026·MŽMatija Žiberna·
Payload
Payload CMS Authentication: Secure Next.js Pages — Proven

Need Help Making the Switch?

Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.

Book Hourly Advisory

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.

// 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:

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:

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.

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

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

// 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:

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:

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

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

No comments yet

Be the first to share your thoughts on this post!

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

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.

Table of Contents

  • How to Authenticate a Custom Page with Payload CMS Auth
  • What Payload Is Actually Using
  • The Protected Page Pattern
  • Why Manual Cookie-Only Headers Break
  • The Login Page Pattern
  • A Server Action for Login
  • Authenticated Route Handlers
  • Biggest Gotchas
  • 1. Rebuilding `Headers` from `cookies()` only
  • 2. Assuming "cookie exists" means "Payload will authenticate"
  • 3. Forgetting to align `csrf` and `cors` with your actual frontend domains
  • 4. Mixing a custom login flow with Payload defaults without checking your collection auth config
  • 5. Underestimating cross-domain setups
  • 6. Using old form abstractions and new form abstractions interchangeably
  • How to Verify It Actually Works
  • 1. Check that the browser has the auth cookie
  • 2. Verify `/login` redirects away when already authenticated
  • 3. Verify `/dashboard` redirects to `/login` when signed out
  • 4. Verify a protected route handler resolves `user`
  • 5. Verify on localhost and production separately
  • Deployment Caveats
  • Local development
  • Production
  • Reverse proxies and CDNs
  • Cross-domain frontends
  • The Main Lesson
  • Minimal Checklist
On this page:
  • How to Authenticate a Custom Page with Payload CMS Auth
  • What Payload Is Actually Using
  • The Protected Page Pattern
  • Why Manual Cookie-Only Headers Break
  • The Login Page Pattern