Skip to main content

Cookie-Based Sessions in Next.js: Complete Implementation

Cookies are the vehicle for session IDs in web applications. An HTTP-only cookie is a small piece of data stored on the client but inaccessible to JavaScript, preventing XSS attacks from stealing it. In Next.js, the cookies() server function lets you read and write cookies securely within Server Components and route handlers. Cookie-based sessions are stateful (the server stores the session), expiring automatically after a set duration, and revokable instantly by deleting the session record.

Understanding HTTP-Only Cookies and Security

An HTTP-only cookie is set via the Set-Cookie HTTP header with the HttpOnly flag. The browser stores the cookie and includes it in subsequent requests, but JavaScript running on the page cannot read or modify it. This blocks XSS: even if malicious code runs in your app, it cannot access the session cookie.

AttributePurposeExample
HttpOnlyBlocks JavaScript from reading the cookieSet in all secure cookies
SecureCookie only sent over HTTPSEnable in production
SameSitePrevents cross-site request forgery (CSRF)Use Lax or Strict
Max-AgeExpiration time in seconds604800 for 7 days
PathRoutes where the cookie is sentUse / for all routes

The SameSite attribute has three values: Strict (never send cross-site), Lax (send on top-level navigations), and None (always send, requires Secure). Use Lax as the default; it protects against most CSRF attacks while allowing legitimate cross-site navigations like clicking a link.

Here is a utility function that sets a secure session cookie with all recommended attributes:

// lib/cookies.ts
import { cookies } from 'next/server';

const SESSION_COOKIE_NAME = 'next.sid';
const SESSION_MAX_AGE = 7 * 24 * 60 * 60; // 7 days in seconds

export async function setSessionCookie(sessionId: string) {
const cookieStore = await cookies();

cookieStore.set(SESSION_COOKIE_NAME, sessionId, {
httpOnly: true, // Prevent JS from reading
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'lax', // Prevent CSRF
maxAge: SESSION_MAX_AGE,
path: '/', // Available on all routes
});
}

export async function getSessionCookie() {
const cookieStore = await cookies();
return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null;
}

export async function deleteSessionCookie() {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
}

In development (HTTP), set secure: false to allow the cookie to be sent over plain HTTP. In production, set secure: true so the browser only sends the cookie over HTTPS. Using process.env.NODE_ENV === 'production' automatically adjusts this.

Storing Sessions in a Database

Sessions must persist on the server so the session ID in the cookie can be validated. Here is a PostgreSQL schema and functions for managing sessions:

-- schema.sql
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
role TEXT DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);

And here are the functions to manage sessions:

// lib/sessions.ts
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export interface SessionData {
userId: string;
role: string;
createdAt: Date;
expiresAt: Date;
lastActivity: Date;
}

export async function createSession(userId: string, role: string = 'user'): Promise<string> {
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

await pool.query(
`INSERT INTO sessions (id, user_id, role, expires_at) VALUES ($1, $2, $3, $4)`,
[sessionId, userId, role, expiresAt]
);

return sessionId;
}

export async function getSession(sessionId: string): Promise<SessionData | null> {
const result = await pool.query(
`SELECT user_id, role, created_at, expires_at, last_activity
FROM sessions
WHERE id = $1 AND expires_at > NOW()`,
[sessionId]
);

if (result.rows.length === 0) return null;

const row = result.rows[0];
return {
userId: row.user_id,
role: row.role,
createdAt: new Date(row.created_at),
expiresAt: new Date(row.expires_at),
lastActivity: new Date(row.last_activity),
};
}

export async function deleteSession(sessionId: string): Promise<void> {
await pool.query(`DELETE FROM sessions WHERE id = $1`, [sessionId]);
}

// Extend session expiry when user is active
export async function refreshSession(sessionId: string): Promise<void> {
const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await pool.query(
`UPDATE sessions SET last_activity = NOW(), expires_at = $1 WHERE id = $2`,
[newExpiresAt, sessionId]
);
}

The refreshSession function extends the session expiry by 7 days when the user is active. Call this function on every request (in middleware or a route handler) to keep active users logged in indefinitely while idle sessions expire after 7 days.

Logging out means deleting the session record and clearing the cookie. Here is a logout route:

// app/api/logout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSessionCookie, deleteSessionCookie } from '@/lib/cookies';
import { deleteSession } from '@/lib/sessions';

export async function POST(req: NextRequest) {
try {
const sessionId = await getSessionCookie();

if (sessionId) {
// Delete session from database
await deleteSession(sessionId);
}

// Clear the cookie
await deleteSessionCookie();

return NextResponse.json({ success: true, message: 'Logged out successfully' });
} catch (error) {
return NextResponse.json({ error: 'Logout failed' }, { status: 500 });
}
}

When a user clicks a logout button, send a POST request to /api/logout. The route deletes the session from the database and clears the cookie. On the next request, the user is unauthenticated.

Session Refresh and Sliding Expiry

A sliding expiry extends the session duration every time the user is active, preventing timeouts during legitimate use. Here is middleware that refreshes sessions automatically:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSession, refreshSession } from '@/lib/sessions';

export async function middleware(request: NextRequest) {
const sessionId = request.cookies.get('next.sid')?.value;

if (!sessionId) {
return NextResponse.next();
}

const session = await getSession(sessionId);
if (!session) {
return NextResponse.redirect(new URL('/login', request.url));
}

// Refresh session if less than 1 hour remains
const timeRemaining = session.expiresAt.getTime() - Date.now();
const oneHour = 60 * 60 * 1000;
if (timeRemaining < oneHour) {
await refreshSession(sessionId);
}

const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', session.userId);
requestHeaders.set('x-user-role', session.role);

return NextResponse.next({ request: { headers: requestHeaders } });
}

export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

This middleware checks if the session will expire within 1 hour and automatically extends it if so. Active users never see a timeout warning; idle users are logged out after 7 days of inactivity.

Key Takeaways

  • HTTP-only cookies prevent JavaScript from accessing the session ID, blocking XSS attacks.
  • Always use Secure flag in production (HTTPS only) and SameSite flag (default Lax) to prevent CSRF.
  • Store sessions in a database with an expiry timestamp; validate the timestamp on every request.
  • Implement session refresh (sliding expiry) to extend active sessions while timing out idle ones.
  • Delete sessions immediately on logout to revoke access instantly.
  • Use a background job to delete expired sessions from the database periodically.

Frequently Asked Questions

Why use HttpOnly cookies instead of storing the session ID in localStorage?

localStorage is vulnerable to XSS because JavaScript can read and modify it. If an attacker injects code into your page, they can steal the token. HTTP-only cookies are inaccessible to JavaScript, so they are safe from XSS. The tradeoff is that you cannot access the token from client-side code, but you should not need to—sessions are a server-side concern.

Use the cookies() function from next/server in Server Components or route handlers. Do not try to set cookies in Client Components; it is not possible. If you need to set a cookie from a client action, call an API route that sets the cookie server-side.

Can I store sensitive data (passwords, tokens) in cookies?

No. Cookies are vulnerable to inspection and tampering (even HTTP-only cookies can be read by other JavaScript). Always store sensitive data on the server; only store the session ID in the cookie. The session ID itself is not sensitive—it is a random string that references server-side data.

How often should I refresh the session?

Refresh when the remaining expiry time is less than 25% of the total session duration. For a 7-day session, refresh if less than 1.75 days remain. This balances security (idle sessions time out) with usability (active users do not get logged out).

Further Reading