Authentication in Next.js
A complete auth pattern using middleware for route protection and httpOnly session cookies.
Where authentication logic actually belongs
Authentication touches three separate concerns that are worth naming individually before writing any code: verifying who someone is (checking a password, validating an OAuth callback), remembering that they're signed in across requests (a session, typically backed by a cookie), and protecting routes so a signed-out visitor can't reach pages that require a signed-in user. Next.js doesn't ship an authentication system of its own — unlike, say, Laravel's built-in auth scaffolding — but the App Router's Server Components, Route Handlers, and middleware give you the right places to hang each of those three concerns without inventing your own conventions from scratch.
In production, most teams reach for a library (Auth.js/NextAuth, Clerk, Lucia) to handle password hashing, OAuth provider integration, and session token generation correctly — that part is easy to get subtly wrong by hand. What's genuinely worth understanding regardless of which library sits underneath is the pattern this page walks through: a session cookie, middleware that checks it before a protected page ever renders, and a Server Component that reads the authenticated user for data that depends on who's signed in.
Session cookies, conceptually
After a successful login, the server sets a cookie in the response — an opaque, signed token identifying the session, not the user's actual credentials. Every subsequent request from that browser automatically includes the cookie, and the server looks it up (or verifies its signature) to know who's making the request, with no need to resend a password on every page load.
// app/api/login/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function POST(request: Request) {
const { email, password } = await request.json();
const user = await verifyCredentials(email, password); // your own auth logic / library call
if (!user) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const sessionToken = await createSession(user.id); // sign or store a session token
const cookieStore = await cookies();
cookieStore.set('session', sessionToken, {
httpOnly: true, // not readable from client-side JavaScript — mitigates XSS token theft
secure: true, // only sent over HTTPS
sameSite: 'lax', // limits the cookie being sent on cross-site requests — mitigates CSRF
path: '/',
maxAge: 60 * 60 * 24 * 7, // one week
});
return NextResponse.json({ success: true });
}
httpOnly: true is the detail that matters most for security here — it makes the cookie invisible to document.cookie in the browser, so a malicious script injected via an XSS vulnerability elsewhere on the page can't simply read and exfiltrate the session token. This is also exactly why session state for authentication belongs in a cookie rather than localStorage: localStorage is always readable by any JavaScript running on the page.
Protecting routes with middleware
middleware.ts, at the project root, runs before a request reaches any route — the right place to check "is this visitor allowed to see this page" once, centrally, rather than repeating an auth check inside every protected page individually:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const PROTECTED_PATHS = ['/dashboard', '/settings'];
export function middleware(request: NextRequest) {
const sessionToken = request.cookies.get('session')?.value;
const isProtectedPath = PROTECTED_PATHS.some((path) =>
request.nextUrl.pathname.startsWith(path)
);
if (isProtectedPath && !sessionToken) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', request.nextUrl.pathname); // redirect back after login
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};
The exported config.matcher tells Next.js which paths should even invoke this middleware — restricting it avoids running the check (and the cookie read) on every single request, including static assets and public pages that don't need it. Middleware runs in a restricted Edge runtime, not a full Node.js environment, which is exactly why the check here only reads the cookie's presence rather than doing a database lookup — verifying the token's signature (a fast, synchronous operation) is a reasonable fit for middleware; looking a session up in a database on every request generally isn't, and is better done in the page or Route Handler itself, only for requests that actually need the full user record.
Reading the authenticated user in a Server Component
Once middleware has confirmed a session cookie is present, an individual protected page still typically needs the actual user record — to greet them by name, scope a query to their account, or check a specific permission:
// app/dashboard/page.tsx
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { getSessionUser } from '@/lib/auth';
export default async function DashboardPage() {
const cookieStore = await cookies();
const sessionToken = cookieStore.get('session')?.value;
const user = sessionToken ? await getSessionUser(sessionToken) : null;
if (!user) {
redirect('/login'); // defense in depth, even though middleware already checked
}
return <h1>Welcome back, {user.name}</h1>;
}
Checking again here, even though middleware already redirected unauthenticated visitors away from /dashboard, is a deliberate belt-and-suspenders pattern rather than redundant code — middleware's matcher config is easy to get subtly wrong (a typo, a new protected route added without updating the matcher), and a page that assumes it can never be reached by an unauthenticated request is one config mistake away from a real security bug. Re-checking inside the page itself, and redirecting again if something's actually wrong, costs almost nothing and closes that gap.
A minimal login form calling the API route
// app/login/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
async function handleSubmit(formData: FormData) {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({
email: formData.get('email'),
password: formData.get('password'),
}),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
setError('Invalid email or password');
return;
}
router.push('/dashboard');
}
return (
<form action={handleSubmit}>
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit">Log in</button>
{error && <p>{error}</p>}
</form>
);
}
This page is a Client Component (it needs useState for the error message and useRouter for the client-side redirect after success) that calls the Route Handler defined earlier — a typical split, where the Route Handler owns the actual auth logic and cookie-setting, and the page owns the interactive form around it.
Common mistakes
- Storing a session token (or worse, a JWT with sensitive claims) in
localStorageinstead of anhttpOnlycookie — anything inlocalStorageis readable by any JavaScript running on the page, making it directly exposed to theft via an XSS vulnerability. - Relying on middleware alone to protect a route, with no defense-in-depth check inside the page or Route Handler itself — a
matchermisconfiguration or a newly added route that wasn't included in it silently leaves that route unprotected. - Doing a real database lookup for the full user record inside middleware on every request — middleware runs on a restricted Edge runtime meant for fast, lightweight checks; heavier verification belongs in the page or Route Handler, run only for requests that actually need it.
- Forgetting
secure: trueand an appropriatesameSitesetting on the session cookie — without them, the cookie can be sent over plain HTTP or attached to cross-site requests in ways that weaken CSRF protection.