Sessions & cookies

Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: How a website remembers you’re logged in across page loads. A small piece of data the browser stores and sends back on every request. Sounds simple; gets surprisingly subtle.


In plain English

HTTP is stateless: every request to a server is independent. The server doesn’t naturally remember anything about who you are between requests. If that were the whole story, you’d have to log in fresh on every page click. Untenable.

Cookies solve this. A cookie is a tiny piece of named data the server tells the browser to remember. The browser stores it locally and automatically sends it back on every future request to that site. So if the server set session_id=abc123 after you logged in, every future request from your browser to that site includes that cookie. The server uses it to identify you.

The data inside the cookie is a session ID — a reference. The actual “George is logged in, here’s his user data” lives on the server (in a database, in-memory store, or encoded into a JWT). The cookie is just the key.

This is a 30-year-old mechanism, and it’s still the foundation of how most webapps remember who you are. There are subtleties — security flags, expiry, cross-site behavior, server-side vs encoded sessions — that we’ll cover. But the core idea is just: browser stores a small key; server uses the key to identify you.


Why it matters

  • Most authentication bugs live here. “I’m randomly being logged out.” “I see another user’s data.” Often traces to cookie misconfiguration or session-handling.
  • Cookie security flags are critical. HttpOnly, Secure, SameSite — wrong values = XSS or CSRF vulnerabilities.
  • Different auth services handle this differently. Supabase uses cookies. NextAuth uses cookies. Clerk uses cookies. Behind the scenes, they all manage cookies; understanding the basics lets you debug across libraries.
  • Mobile apps use tokens, not cookies. Knowing the cookie model helps you see when not to use it.

How cookies actually work

  1. Browser makes a request: GET /login.
  2. User submits login form: POST /api/login with email + password.
  3. Server verifies credentials, generates a session ID, stores it server-side (or signs it into a JWT), and sets a cookie in the response:
HTTP/1.1 200 OK
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000
Content-Type: application/json
{ "success": true }
  1. Browser stores the cookie associated with the site’s origin.
  2. On every subsequent request to the same site, the browser automatically includes the cookie:
GET /dashboard
Cookie: session_id=abc123
  1. The server reads the session ID, looks up the user, and processes the request as authenticated.

This happens automatically; no JavaScript needed (and in fact, HttpOnly cookies aren’t accessible to JavaScript at all).


The most important attributes:

HttpOnly

The cookie is invisible to JavaScript. document.cookie won’t see it. This protects against XSS — even if attacker JS runs on your page, it can’t steal an HttpOnly cookie.

Always use HttpOnly for auth cookies.

Secure

The cookie is only sent over HTTPS. Plain HTTP requests don’t include it. Protects against interception over WiFi.

Always use Secure in production.

SameSite

Controls whether the cookie is sent on cross-site requests. Three values:

  • Strict — only sent for same-site requests. Most secure but breaks some legitimate flows.
  • Lax (modern browser default) — sent on top-level cross-site navigation but not embedded resources. Reasonable balance.
  • None — sent on all cross-site requests. Requires Secure. Use only when needed (e.g. cross-origin auth flows).

Lax is the standard for most auth cookies. None only for specific OAuth/SAML flows.

Path

Cookie is only sent for requests under this path. Usually / (the whole site).

Domain

Which domain the cookie applies to. By default, just the exact host. Setting to .example.com shares across subdomains.

Max-Age or Expires

How long the cookie lasts. Max-Age=3600 = 1 hour. Without it, the cookie is a session cookie — gone when the browser closes.

A complete auth cookie typically looks like:

Set-Cookie: session=abc123;
            HttpOnly;
            Secure;
            SameSite=Lax;
            Path=/;
            Max-Age=2592000

Server-side sessions vs encoded sessions

Two patterns for what the cookie actually references:

Server-side sessions (traditional)

The cookie contains a random ID. The server stores a record like:

sessions:
  abc123 → user_id: 42, expires: 2026-07-19
  def456 → user_id: 17, expires: 2026-06-25

On each request, server looks up the ID in the store (Redis, Postgres, in-memory) and finds the user.

Pros: can invalidate any session immediately (just delete the record); small cookie size. Cons: requires server-side storage; doesn’t scale trivially across multiple servers without shared session store.

Encoded sessions (JWT)

The cookie contains a signed token with the user data encoded in it. No server-side lookup needed; the server verifies the signature and trusts the contents.

session=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsImV4cCI6MTcyMjM0NTYwMH0.signature

Pros: stateless servers; no storage needed; scales trivially. Cons: can’t invalidate before expiry without a blocklist; larger cookies; harder to revoke.

Modern auth tends to combine both: short-lived JWT access tokens + a long-lived session cookie that allows refresh. Supabase works this way.

See JWT for the encoded variant in depth.


Cookies in Next.js (App Router, 2026)

In the App Router, cookies are read from server-side code via the cookies() helper:

import { cookies } from "next/headers";
 
export default async function Page() {
  const cookieStore = await cookies();
  const session = cookieStore.get("session_id");
 
  if (!session) return <p>Please log in.</p>;
  // ... look up user ...
}

Setting cookies happens in Server Actions or Route Handlers:

"use server";
import { cookies } from "next/headers";
 
export async function login(formData: FormData) {
  // ... verify credentials ...
  const cookieStore = await cookies();
  cookieStore.set("session_id", sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 30, // 30 days
    path: "/"
  });
}

In middleware, cookies are accessible via request.cookies:

import { NextResponse, NextRequest } from "next/server";
 
export function middleware(request: NextRequest) {
  const session = request.cookies.get("session_id");
  if (!session && request.nextUrl.pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
}

Supabase Auth on Next.js wraps all this — its middleware handles refreshing session cookies on every request, so you usually just write getUser() and let it work.


A concrete example: the full login flow

Step by step:

1. Browser:  GET /login
2. Server:   200 OK (the login page HTML)

3. Browser:  POST /api/login { email, password }
4. Server:   Verify credentials (bcrypt compare)
             Generate session_id = "random_uuid"
             Store { session_id → user_id } in Redis
             Set-Cookie: session=random_uuid; HttpOnly; Secure; SameSite=Lax
             302 Redirect to /dashboard

5. Browser:  GET /dashboard
             (automatically includes Cookie: session=random_uuid)
6. Server:   Read cookie, look up user_id in Redis
             Render dashboard for that user

7. Browser:  GET /api/data
             (cookie included again)
8. Server:   Same lookup, return data

9. Browser:  POST /api/logout
10. Server:  Delete session record from Redis
             Set-Cookie: session=; Max-Age=0 (instruct browser to remove)
             302 Redirect to /

Every step’s cookie behavior is automatic from the browser’s side. The server does the work.


Session lifetime patterns

Different sites handle session length differently:

Short sessions

1-24 hours. User logs in often. Good for sensitive sites (banks). Annoying for casual sites.

Long sessions with refresh

Access token expires in 15 minutes; refresh token in 30 days. Browser silently refreshes the access token using the refresh token. User stays “logged in” for 30 days, but a stolen access token has limited lifetime.

This is the modern default for most webapps (Supabase, NextAuth, Clerk).

”Remember me” checkbox

Toggles between long and short session at login time.

Sliding expiry

Session extends on every activity. Idle users get logged out; active users stay in.


Common gotchas

  • Missing HttpOnly. Attacker JS can read the cookie via document.cookie and steal sessions. Always use HttpOnly for auth cookies.

  • Missing Secure in production. Cookie sent over plain HTTP can be intercepted. Always use Secure in production. localhost HTTP is the only exception.

  • Wrong SameSite. None without Secure is rejected by modern browsers. Strict may break legitimate OAuth flows. Lax is usually right.

  • Cookies not refreshing. Long sessions need either a long Max-Age or active refresh. Without either, user gets logged out unexpectedly.

  • Cookies set on wrong path/domain. Cookie set with Path=/admin won’t be sent for /dashboard. Subtle source of “I keep getting logged out.”

  • Cookie size limits. Browsers cap cookies at ~4KB per cookie, ~50 per domain. JWT payloads can grow large. If your cookie is approaching the limit, reconsider.

  • Setting cookies in client-side code. document.cookie = "..." works for non-HttpOnly cookies but is rarely the right tool. Server-set cookies are safer.

  • Forgetting await on cookies() in Next.js 15+. It’s a Promise now. cookies().get(...) no longer works directly.

  • CSRF without protection. A site can make POST requests with cookies attached automatically. Without CSRF tokens or strict SameSite, anyone can submit forms as you. See CSRF.

  • Cookie not invalidated on logout. Server still has the session record; browser still has the cookie. Both should be cleared on logout.

  • Cross-subdomain cookie issues. app.example.com and api.example.com need cookies to apply to both → set Domain=.example.com. But this also exposes to ALL subdomains.

  • Time zone bugs in expiry. Expires is GMT/UTC. Don’t compare with local time. Max-Age is simpler.

  • Cookie name collision. Two libraries both setting session? They’ll fight. Use specific names.

  • Reading cookies in Server Components without refreshing. Server Components see the cookie at render time. If you change it, refresh or re-render.

  • localStorage for auth tokens. Don’t. Vulnerable to XSS. Cookies (HttpOnly, Secure, SameSite) are the right tool.

  • Mobile apps reading website cookies. Mobile apps usually use API tokens (Authorization header), not cookies. Don’t mix the two.

  • Forgetting to delete sessions server-side on logout. If only the cookie is cleared, an attacker who copied the cookie before logout can still use it.

  • Session fixation. Attacker forces a user’s session ID to a value the attacker knows. Mitigation: rotate session ID on login.


See also

Sources