- Payload CMS Cookie Auth: 7 Troubleshooting Secrets for Next.js
Payload CMS Cookie Auth: 7 Troubleshooting Secrets for Next.js
Implement Payload CMS HTTP-only cookie auth with Next.js App Router: CSRF, sessions, CORS, and subdomain cookies.

Need Help Making the Switch?
Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.
Book Hourly AdvisoryRelated Posts:
If you are building gated content, customer dashboards, or member-only areas with Payload CMS and Next.js, your first instinct might be to reach for NextAuth, Clerk, or Auth.js. Most developers do. What they do not realize is that Payload already ships with a complete, production-ready authentication system that works on any collection, not just the admin users collection.
I have set this up on multiple client projects where the requirement was straightforward: customers need to log in, see content that is only for them, and stay logged in across sessions. No social login, no magic links, just email and password with proper security. Payload handles this natively through HTTP-only cookies, JWTs, CSRF protection, and optional server-side sessions, all without adding a single auth dependency.
This guide walks through the full implementation: from creating an auth-enabled collection, to integrating login and auth checks in the Next.js App Router, to the specific configuration mistakes that will cause silent 401 failures in production. That last part is where most people get stuck, because Payload's cookie auth works perfectly in development and then breaks behind a reverse proxy or on a different subdomain with no useful error message.
What Payload's cookie auth actually is
Before writing any code, it helps to understand the mechanism. Payload's auth system is not a simplified token store. It is a JWT-based authentication strategy where the token lives inside an HTTP-only cookie instead of localStorage or a regular cookie that JavaScript can read.
When a user logs in, Payload verifies their credentials, generates a JWT, and sets it as an HTTP-only cookie (typically named payload-token) via the Set-Cookie header. On every subsequent request, the browser automatically includes that cookie. Payload reads it, verifies the JWT, and either populates req.user or rejects the request.
The HTTP-only part is the security win. Because JavaScript cannot access the cookie through document.cookie, an XSS vulnerability on your frontend cannot steal the token. The browser sends it automatically, and only the server can read it.
There are a few things Payload layers on top of this:
Server-side sessions are enabled by default (useSessions: true). This means Payload maintains session records on the server keyed to each JWT. This gives you the ability to revoke sessions and log users out across all devices, something pure stateless JWTs cannot do.
CSRF protection is built in. Payload only accepts cookie-based auth from domains you explicitly whitelist in the csrf array in your config. If a request comes from a domain not on that list, the cookie is rejected even if it is valid. This is critical for cross-origin setups and is the source of most "it works locally but not in production" issues.
Token refresh keeps sessions alive. While the token is still valid, you can call the refresh endpoint to get a new token with an updated expiration. The old cookie is replaced automatically.
This entire system works on any Payload collection with auth enabled, not just the default users collection. That is the key point. You create a customers or members collection, enable auth on it, and Payload gives it its own login, logout, me, and refresh endpoints at /api/customers/login, /api/customers/me, and so on.
Setting up a customer auth collection
The foundation is an auth-enabled collection. This is where you define how authentication behaves for your non-admin users.
// File: src/collections/Customers.ts
import type { CollectionConfig } from 'payload'
export const Customers: CollectionConfig = {
slug: 'customers',
auth: {
tokenExpiration: 60 * 60 * 24 * 3, // 3 days in seconds
useSessions: true,
cookies: {
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
domain: undefined, // set to '.example.com' for subdomain sharing
httpOnly: true,
},
maxLoginAttempts: 5,
lockTime: 600 * 1000, // 10 minutes
verify: false,
},
fields: [
{
name: 'firstName',
type: 'text',
required: true,
},
{
name: 'lastName',
type: 'text',
required: true,
},
{
name: 'role',
type: 'select',
options: ['customer', 'vip'],
defaultValue: 'customer',
saveToJWT: true,
},
],
}
A few things to pay attention to here.
tokenExpiration controls how long both the JWT and the cookie are valid. They expire together. Three days is a reasonable default for customer-facing apps where you do not want people logging in every session but also do not want tokens living for weeks.
useSessions: true is the default, and I recommend keeping it. With sessions enabled, calling the logout endpoint actually invalidates the session on the server. Without sessions, logout just clears the cookie on the client, but anyone who captured the JWT could still use it until it expires.
The cookies block is where most production issues originate. secure: true means the cookie is only sent over HTTPS, which is correct for production but will silently fail on http://localhost in development. The conditional based on NODE_ENV handles both environments.
sameSite: 'lax' works for same-domain and subdomain setups. If your Payload API and your Next.js frontend are on completely different domains (not subdomains), you need sameSite: 'none' paired with secure: true. More on this in the failure modes section.
The saveToJWT: true on the role field encodes that value directly into the JWT. This means you can check req.user.role in access control functions and hooks without making an extra database query. Use this for fields you check frequently during authorization decisions.
Configuring CSRF, CORS, and serverURL
The collection config is only half the picture. The global Payload config needs to know which domains are allowed to use cookie-based auth.
// File: src/payload.config.ts
import { buildConfig } from 'payload'
import { Customers } from './collections/Customers'
export default buildConfig({
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL, // e.g. https://api.example.com
csrf: [
'https://example.com',
'https://www.example.com',
],
cors: [
'https://example.com',
'https://www.example.com',
],
collections: [Customers],
// ... rest of your config
})
serverURL is the most important setting here, and it is the most commonly misconfigured. Payload uses it for CSRF checks, email verification links, and internal URL generation. If this is wrong, if it says http when your site is on https, or if it is missing entirely, cookie auth will fail with 401s that give you no indication of the actual problem.
Payload automatically adds serverURL to the CSRF whitelist. If your serverURL is set to https://api.example.com but your frontend lives at https://example.com, you must add the frontend domain to the csrf array explicitly. Without this, every authenticated request from your frontend will be silently rejected.
The csrf array tells Payload which origins are allowed to send cookie-based requests. If a request arrives with an Origin header that does not match any entry in this list (or serverURL), Payload rejects the cookie. This is intentional CSRF protection, but it means you need to be deliberate about listing every domain that will interact with Payload using cookies.
cors controls which origins can make cross-origin requests at all. When using cookies, you cannot use a wildcard ('*'). You must list domains explicitly. This is a browser-level restriction, not a Payload-specific one.
Login flow in Next.js App Router
With the collection and config in place, the next step is implementing the actual login. The pattern here depends on whether Payload runs on the same domain as your Next.js app or on a separate domain.
Same-domain setup
If Payload and Next.js are served from the same domain (which is the case when Payload is embedded in your Next.js app), the login flow is straightforward.
// File: src/app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const body = await req.json()
const loginRes = await fetch(
`${process.env.PAYLOAD_API_URL}/api/customers/login`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
)
if (!loginRes.ok) {
const error = await loginRes.json()
return NextResponse.json(
{ error: error.message || 'Invalid credentials' },
{ status: 401 },
)
}
const data = await loginRes.json()
// Forward Payload's Set-Cookie header to the browser
const response = NextResponse.json(data)
const setCookie = loginRes.headers.get('set-cookie')
if (setCookie) {
response.headers.set('set-cookie', setCookie)
}
return response
}
This route handler acts as a proxy between your client-side login form and Payload's auth endpoint. When Payload verifies the credentials successfully, it includes a Set-Cookie header in its response with the HTTP-only payload-token cookie. By forwarding that header to the browser, the cookie gets stored and will be sent automatically on all subsequent requests to the same domain.
The client-side form that calls this route:
// File: src/components/LoginForm.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export function LoginForm() {
const router = useRouter()
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setError(null)
const formData = new FormData(e.currentTarget)
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.get('email'),
password: formData.get('password'),
}),
credentials: 'include',
})
if (!res.ok) {
const data = await res.json()
setError(data.error || 'Login failed')
return
}
router.push('/dashboard')
router.refresh()
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
{error && <p>{error}</p>}
<button type="submit">Log in</button>
</form>
)
}
The credentials: 'include' on the fetch call is essential. Without it, the browser will not store the Set-Cookie header from the response, and the login will appear to succeed but the user will not actually be authenticated on subsequent requests. This is the single most common mistake in Payload cookie auth implementations.
Subdomain setup
If Payload runs on api.example.com and your Next.js app on example.com, two additional things change.
First, the cookie domain in your Customers collection must be set to the parent domain:
cookies: {
secure: true,
sameSite: 'lax',
domain: '.example.com', // note the leading dot
httpOnly: true,
},
This tells the browser to send the payload-token cookie to any subdomain of example.com, including both api.example.com and example.com.
Second, the client-side login call goes directly to the Payload API instead of through a Next.js route handler:
await fetch('https://api.example.com/api/customers/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include',
})
Because this is a cross-origin request, credentials: 'include' is not optional. And your Payload cors and csrf config must include https://example.com as shown earlier.
Checking auth status in server components
Once the user is logged in and the cookie is set, you need a way to determine whether the current visitor is authenticated. In the Next.js App Router, this happens on the server by forwarding the incoming cookies to Payload's me endpoint.
// File: src/lib/auth.ts
import { cookies } from 'next/headers'
export async function getCurrentCustomer() {
const cookieStore = await cookies()
const res = await fetch(
`${process.env.PAYLOAD_API_URL}/api/customers/me`,
{
headers: {
cookie: cookieStore.toString(),
},
cache: 'no-store',
},
)
if (!res.ok) return null
const data = await res.json()
return data.user ?? null
}
This function reads the cookies from the incoming Next.js request and forwards them to Payload. Payload extracts the payload-token, verifies the JWT (and the session if useSessions is enabled), runs its CSRF checks, and returns the user object if everything is valid.
You use this in any server component:
// File: src/app/dashboard/page.tsx
import { getCurrentCustomer } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const customer = await getCurrentCustomer()
if (!customer) {
redirect('/login')
}
return (
<div>
<h1>Welcome, {customer.firstName}</h1>
{/* Protected content here */}
</div>
)
}
The cache: 'no-store' on the fetch is important. Without it, Next.js might cache the response and serve stale auth state, showing logged-in content to logged-out users or vice versa.
If Payload is embedded in the same Next.js application (not running as a separate API server), you can use Payload's Local API instead of HTTP calls. The Local API bypasses the HTTP layer entirely, which means no CSRF checks and no cookie parsing. You would pass the user context directly. However, for understanding the cookie strategy specifically, the HTTP pattern above is what matters.
Protecting routes with middleware
For cases where you want to gate entire route segments without checking auth inside every page component, Next.js middleware gives you a centralized approach.
// File: src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const protectedPaths = ['/dashboard', '/account', '/members']
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
if (!protectedPaths.some((p) => pathname.startsWith(p))) {
return NextResponse.next()
}
const meRes = await fetch(
`${process.env.PAYLOAD_API_URL}/api/customers/me`,
{
headers: {
cookie: req.headers.get('cookie') || '',
},
cache: 'no-store',
},
)
if (!meRes.ok) {
const loginUrl = new URL('/login', req.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
const { user } = await meRes.json()
if (!user) {
const loginUrl = new URL('/login', req.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*', '/members/:path*'],
}
This middleware runs before any page in the matched routes. It forwards the raw Cookie header from the incoming request to Payload's me endpoint and redirects to login if the user is not authenticated. The redirect query parameter lets you bounce the user back to where they were trying to go after they log in.
One thing to be aware of: this approach makes an HTTP call to Payload on every navigation to a protected route. For most applications this is fine since the me call is fast. But if latency is a concern, you can check for the existence of the payload-token cookie in middleware as a quick pre-check (the cookie exists means the user probably has a valid session) and do the full verification in the server component. The cookie is HTTP-only so you cannot read its value, but you can check whether it exists in the request headers.
Logout and session management
Logout has a subtle but important detail: because the cookie is HTTP-only, you cannot clear it from client-side JavaScript. You must call Payload's logout endpoint, which clears the cookie server-side.
// File: src/app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const logoutRes = await fetch(
`${process.env.PAYLOAD_API_URL}/api/customers/logout`,
{
method: 'POST',
headers: {
cookie: req.headers.get('cookie') || '',
},
},
)
const response = NextResponse.json({ success: true })
// Forward Payload's Set-Cookie that clears the token
const setCookie = logoutRes.headers.get('set-cookie')
if (setCookie) {
response.headers.set('set-cookie', setCookie)
}
return response
}
On the client side:
async function handleLogout() {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
})
router.push('/login')
router.refresh()
}
If you need to log a user out of all their devices (for example, after a password change), pass ?allSessions=true to the logout endpoint. This only works when useSessions: true is set on the collection, because Payload needs server-side session records to know which sessions to invalidate.
Token refresh strategy
The refresh endpoint extends the user's session without requiring them to log in again. It only works while the current token is still valid. Once a token expires, refresh fails and the user must log in again.
// File: src/lib/refreshToken.ts
export async function refreshCustomerToken() {
const res = await fetch('/api/customers/refresh-token', {
method: 'POST',
credentials: 'include',
})
if (!res.ok) return null
const data = await res.json()
return data // { user, refreshedToken, exp }
}
For customer-facing applications, there are two practical strategies.
The first is to use a longer tokenExpiration (like 3-7 days) and not bother with active refresh. The user stays logged in for the duration, and when the token expires, they log in again. This is the simpler approach and works well when the security requirements are not extreme.
The second is to use a shorter tokenExpiration (20-30 minutes) and refresh the token in the background during active use. You would call the refresh endpoint on route changes or at a regular interval while the user is active. This gives you tighter control over session duration but adds complexity.
There was a reported bug in some Payload versions where the refresh endpoint set the new cookie with the old token value instead of the newly generated one. The cookie expiration got extended, but the JWT exp claim inside it did not. If you depend on strict token rotation, verify this is fixed in your Payload version by checking the actual JWT contents after a refresh call.
The failure modes you will hit in production
This is the section that will save you hours. Payload's cookie auth works seamlessly in local development and then breaks in specific, hard-to-diagnose ways once you deploy.
401 after successful login
You call the login endpoint, get a 200 with user data, see the cookie in DevTools, and then every subsequent API call returns "Unauthorized, you must be logged in to make this request."
The cause is almost always one of these:
Missing credentials: 'include' on client-side fetch calls. Without this, the browser does not send the cookie on cross-origin requests, or does not store the Set-Cookie from the response. Add it to every fetch that interacts with Payload.
CSRF origin mismatch. Your frontend origin is not in the csrf array. Payload sees the cookie but rejects it because the request comes from an untrusted origin. Add your frontend domain to csrf in payload.config.ts.
serverURL protocol mismatch. Your serverURL says http://example.com but your app is served over https. Payload's CSRF check compares the request origin against serverURL and rejects the mismatch. Make sure serverURL includes the correct protocol.
Cookie set but not sent on subsequent requests
Login succeeds, the cookie appears in your browser's cookie storage, but requests to Payload do not include it.
This happens with sameSite and domain misconfigurations. If your API is on a different domain (not subdomain) and sameSite is set to 'lax' or 'strict', the browser will not send the cookie on cross-origin requests. For truly cross-domain setups, you need sameSite: 'none' with secure: true.
If you are sharing cookies across subdomains, the domain field in the cookie config must be set to the parent domain with a leading dot (.example.com). Without this, the cookie is scoped to the exact subdomain that set it.
Works locally, 401 behind reverse proxy
This is the most frustrating failure mode. Everything works in development, but once you deploy behind nginx or Traefik, authenticated requests fail.
The reverse proxy is likely stripping or not forwarding the headers Payload needs for CSRF verification. Payload checks the Origin and Referer headers against its CSRF whitelist, and if the proxy rewrites or drops these, the check fails.
Make sure your proxy forwards Host, X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-For. If you are using Payload behind a trusted proxy, you may also need to set rateLimit: { trustProxy: true } in your Payload config so it reads the forwarded headers correctly.
Server actions or middleware losing auth context
If you are running on an edge runtime (Cloudflare Workers, for example), Payload's server actions can fail to validate cookies even when they are present in the request. The cookies reach the server, but the validation logic behaves differently on edge runtimes compared to Node.js.
For edge deployments, treat Payload as an external HTTP API and make fetch calls to it rather than trying to use the Local API or server functions directly. If you are running Payload's admin UI on Cloudflare Workers specifically, be aware that there are known issues with server actions losing auth context in that environment.
When debugging cookie auth issues, start by checking these three things in order: (1) Is serverURL set correctly with the right protocol? (2) Is your frontend domain in the csrf array? (3) Are you using credentials: 'include' on every client-side fetch? These three cover roughly 80% of all cookie auth failures with Payload.
When to use a third-party auth provider instead
Payload's native auth covers email/password login, JWT tokens, HTTP-only cookies, server-side sessions, CSRF protection, and basic rate limiting. That is a complete auth system for many applications.
But there are situations where it is not enough:
If you need social login (Google, GitHub, Apple), Payload does not provide this out of the box. You would need to build custom OAuth flows or integrate a provider that handles them.
If you need magic links or passwordless authentication, you would need to implement this yourself on top of Payload's auth hooks.
If you need complex RBAC with hierarchical roles, permission inheritance, or fine-grained resource-level access, Payload's access control functions are powerful but you are writing the logic yourself rather than configuring a managed system.
If your application is primarily a consumer-facing SaaS with thousands of concurrent users and you need features like device management, session monitoring dashboards, and fraud detection, a dedicated auth platform like Clerk or Auth0 is purpose-built for that scale of identity management.
For content gating, customer portals, member areas, and B2B applications where the user base is manageable and the auth requirements are standard email/password with session management, Payload's native auth is the right choice. You avoid adding a dependency, you keep user data in your own database, and you get the full flexibility of Payload's hooks and access control to customize behavior.
Wrapping up
Payload's cookie auth for non-admin collections is a production-ready system that most developers underestimate. The mechanism is sound: JWT in an HTTP-only cookie with CSRF protection, optional server-side sessions, and built-in refresh. The implementation in Next.js App Router follows a clear pattern of forwarding cookies from the incoming request to Payload's me endpoint on the server and using credentials: 'include' on every client-side call.
The hard part is not the implementation. It is the configuration. Getting serverURL, csrf, cors, cookie domain, and sameSite aligned correctly for your specific deployment topology is where the real debugging happens. If you take one thing from this guide, let it be the debugging checklist: serverURL protocol, csrf whitelist, credentials include.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija
📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.


