Emmanuel Akinfulubi

Auth by hand all 2 written

Sessions built from node:crypto and a signed cookie, and protecting routes in two places.

26 Sessions by hand

Next.js does not ship a login system, and this course has none to vendor, so this lesson builds the smallest honest one from node:crypto. A session is how a server remembers who you are between requests, since HTTP itself remembers nothing. There are two pieces to get right: how a password is stored, and how the server recognises the person on the next request. Everything cryptographic is in one plain file, app/_lib/session-core.js, that does not import Next.js, so the panels below can load it directly and lesson 28 can test it. The Node.js course covers the primitives (scrypt, HMAC); this lesson uses them.

Storing a password

A server must never store a password, only something it can compare a guess against. hashPassword makes a random salt, runs the password and salt through scrypt, which is deliberately slow and memory-hungry so that trying a billion guesses is expensive, and stores salt:hash. The salt means two people with the same password get different strings, and a table of precomputed hashes is useless. To check a login, verifyPassword hashes the guess with the stored salt and compares the two in constant time. The panel prints the shape of the stored string with every hex digit replaced by an x, since the real digits change on every run. (A driver sits in a folder next to the project, and the course names the project's folder app, so ../app/app/_lib/session-core.js is the file app/_lib/session-core.js of the project.)

hashPassword and verifyPassword, called directly

ran at build time · next build + next start · Next 16.3.1

app/_lib/session-core.js

// Everything about passwords and signed tokens that does not need Next.js. Plain functions
// over node:crypto, so they can be tested in a terminal (lesson 28).
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';

/** "<salt>:<hash>", both hex. scrypt is slow on purpose; the salt makes equal passwords differ. */
export function hashPassword(password, salt = randomBytes(16).toString('hex')) {
  return `${salt}:${scryptSync(password, salt, 32).toString('hex')}`;
}

export function verifyPassword(password, stored) {
  const [salt, hash] = String(stored).split(':');
  if (!salt || !hash) return false;
  const expected = Buffer.from(hash, 'hex');
  const actual = scryptSync(password, salt, expected.length);
  return timingSafeEqual(actual, expected);
}

/** "<payload>.<signature>", both base64url. The payload is readable by anyone; only the signature needs the secret. */
export function signSession(payload, secret) {
  const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
  const signature = createHmac('sha256', secret).update(body).digest('base64url');
  return `${body}.${signature}`;
}

/** The payload, or null if the token is malformed, tampered with, or expired. */
export function readSession(token, secret, now = Date.now()) {
  if (typeof token !== 'string') return null;
  const [body, signature, ...extra] = token.split('.');
  if (!body || !signature || extra.length > 0) return null;
  const expected = createHmac('sha256', secret).update(body).digest();
  const given = Buffer.from(signature, 'base64url');
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null;
  try {
    const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
    return payload.exp > now ? payload : null;
  } catch {
    return null;
  }
}

password.mjs

import { hashPassword, verifyPassword } from '../app/app/_lib/session-core.js';

const stored = hashPassword('correct horse');
const again = hashPassword('correct horse');

console.log('stored:', stored.replace(/[0-9a-f]/g, 'x'));
console.log('same password hashed twice gives the same string:', stored === again);
console.log('right password:', verifyPassword('correct horse', stored));
console.log('wrong password:', verifyPassword('correct hors', stored));

check('the stored string is a 16-byte salt and a 32-byte hash, in hex', /^[0-9a-f]{32}:[0-9a-f]{64}$/.test(stored), true);
check('the same password hashed twice gives two different strings', stored === again, false);
check('and both verify', [verifyPassword('correct horse', stored), verifyPassword('correct horse', again)], [true, true]);
check('a wrong password does not', verifyPassword('correct hors', stored), false);
check('and neither does a stored string that is not in the format', [verifyPassword('x', ''), verifyPassword('x', 'nonsense')], [false, false]);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node password.mjs

stored: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
same password hashed twice gives the same string: false
right password: true
wrong password: false
ok   the stored string is a 16-byte salt and a 32-byte hash, in hex
ok   the same password hashed twice gives two different strings
ok   and both verify
ok   a wrong password does not
ok   and neither does a stored string that is not in the format

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

Recognising the person: a signed token

After a good login the server needs something the browser will send back on every request. A random id looked up in a table is one way. The other is to put the facts in the token itself and sign it, which is what signSession does: a small JSON payload (the user's id, an expiry time), encoded, then a signature made with a secret that only the server knows. The payload is not encrypted; anyone can read it. The signature is what matters: change one character of the payload and the signature stops matching, and nobody can make a new one without the secret. readSession recomputes the signature, compares it, and only then trusts the payload. The panel passes the clock in as an argument (now) so that "expired" needs no waiting.

signSession and readSession, called directly

ran at build time · next build + next start · Next 16.3.1

token.mjs

import { readSession, signSession } from '../app/app/_lib/session-core.js';

const secret = 'a secret that only the server knows';
const token = signSession({ sub: 7, exp: Date.now() + 60_000 }, secret);
const [body, signature] = token.split('.');
const payload = JSON.parse(Buffer.from(body, 'base64url').toString());

// An attacker who edits the payload to become user 1 but keeps the old signature:
const forged = Buffer.from(JSON.stringify({ ...payload, sub: 1 })).toString('base64url') + '.' + signature;

console.log('the payload anyone can read:', Object.keys(payload).join(', '));
console.log('read back with the secret :', readSession(token, secret)?.sub);
console.log('forged payload            :', readSession(forged, secret));
console.log('wrong secret              :', readSession(token, 'another secret'));
console.log('two minutes later         :', readSession(token, secret, Date.now() + 120_000));

check('the payload holds what was signed, readable by anyone', [payload.sub, Object.keys(payload).sort()], [7, ['exp', 'sub']]);
check('the right secret gives the payload back', readSession(token, secret).sub, 7);
check('a payload changed after signing is refused', readSession(forged, secret), null);
check('a different secret is refused', readSession(token, 'another secret'), null);
check('an expired token is refused', readSession(token, secret, Date.now() + 120_000), null);
check('so are a malformed string, nothing at all and too many parts', [readSession('abc', secret), readSession(undefined, secret), readSession('a.b.c', secret)], [null, null, null]);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node token.mjs

the payload anyone can read: sub, exp
read back with the secret : 7
forged payload            : null
wrong secret              : null
two minutes later         : null
ok   the payload holds what was signed, readable by anyone
ok   the right secret gives the payload back
ok   a payload changed after signing is refused
ok   a different secret is refused
ok   an expired token is refused
ok   so are a malformed string, nothing at all and too many parts

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

A token like this is stateless: the server keeps no list of sessions, so there is nothing to look up and nothing to clean up. The cost shows up at logout, further down.

The cookie, from sign-up

The sign-up page is a server component with a <form action={signup}> (lesson 17). The action validates the input with the function from lesson 18, stores the user with a hashed password, creates the session and redirects. Creating the session is cookies().set(...) with four settings that each close a hole: httpOnly keeps the cookie out of reach of JavaScript on the page, so a script injected into the page cannot steal it; secure (on when NODE_ENV is production, as under next start) keeps it off unencrypted connections; sameSite: 'lax' keeps the browser from sending it with most requests started by other sites; maxAge gives it a lifetime. The panel posts the form the way a browser with JavaScript off would, then opens a page with the cookie.

Sign up with no JavaScript, and read the cookie

ran at build time · next build + next start · Next 16.3.1

app/auth/actions.js

'use server';
import { redirect } from 'next/navigation';
import { createSession, destroySession } from '../_lib/session.js';
import { hashPassword, verifyPassword } from '../_lib/session-core.js';
import { createUser, findUser } from '../_lib/users.js';
import { validateSignup } from '../_lib/validate.js';

const back = (path, message) => redirect(`${path}?error=${encodeURIComponent(message)}`);

export async function signup(formData) {
  const values = {
    name: String(formData.get('name') ?? ''),
    email: String(formData.get('email') ?? '').trim().toLowerCase(),
    password: String(formData.get('password') ?? ''),
  };
  const problems = Object.values(validateSignup(values));
  if (problems.length > 0) back('/signup', problems[0]);
  if (findUser(values.email)) back('/signup', 'That email is already registered.');
  const user = createUser({ name: values.name.trim(), email: values.email, passwordHash: hashPassword(values.password) });
  await createSession(user.id);
  redirect('/account');
}

export async function login(formData) {
  const email = String(formData.get('email') ?? '').trim().toLowerCase();
  const user = findUser(email);
  // The same message for an unknown email and a wrong password, so the form does not tell which accounts exist.
  if (!user || !verifyPassword(String(formData.get('password') ?? ''), user.passwordHash)) back('/login', 'Wrong email or password.');
  await createSession(user.id);
  redirect('/account');
}

export async function logout() {
  await destroySession();
  redirect('/login');
}

app/_lib/session.js

// The session layer for server code: reads and writes the cookie, and decides who is signed in.
import 'server-only';
import { randomBytes } from 'node:crypto';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { readSession, signSession } from './session-core.js';
import { findUserById } from './users.js';

export const COOKIE = 'shelf_session';
// Set SESSION_SECRET in production, or sessions end whenever the server restarts.
const SECRET = process.env.SESSION_SECRET || (globalThis.__shelfSecret ??= randomBytes(32).toString('hex'));

export async function createSession(userId) {
  const token = signSession({ sub: userId, exp: Date.now() + 60 * 60 * 1000 }, SECRET);
  (await cookies()).set(COOKIE, token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60,
  });
}

/** The signed-in user, or null. Never redirects: route handlers use it to answer 401. */
export async function getSession() {
  const payload = readSession((await cookies()).get(COOKIE)?.value, SECRET);
  return (payload && findUserById(payload.sub)) || null;
}

/** The signed-in user, or a redirect to /login. Call it in every page and action that needs one. */
export async function verifySession() {
  const user = await getSession();
  if (!user) redirect('/login');
  return user;
}

export async function destroySession() {
  (await cookies()).delete(COOKIE);
}

signup.mjs

const base = process.env.BASE_URL;
const ids = (html) => [...html.matchAll(/name="(\$ACTION_ID_[0-9a-f]+)"/g)].map((m) => m[1]);
async function post(path, fields) {
  const page = await (await fetch(base + path)).text();
  const body = new FormData();
  body.append(ids(page)[0], '');
  for (const [name, value] of Object.entries(fields)) body.append(name, value);
  return fetch(base + path, { method: 'POST', body, redirect: 'manual', headers: { origin: base } });
}
const where = (res) => new URL(res.headers.get('location'), base).pathname + new URL(res.headers.get('location'), base).search;

const res = await post('/signup', { name: 'Ada', email: 'ada@example.com', password: 'correct horse' });
const cookies = res.headers.getSetCookie();
const [pair, ...attributes] = cookies[0].split('; ');
const token = pair.slice(pair.indexOf('=') + 1);
const payload = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString());
const account = await (await fetch(base + '/account', { headers: { cookie: pair } })).text();

console.log('POST /signup ->', res.status, where(res));
console.log('cookies set:', cookies.length, ' name:', pair.slice(0, pair.indexOf('=')));
console.log('attributes:', attributes.map((a) => a.split('=')[0]).join(', '), ' SameSite is', attributes.find((a) => a.startsWith('SameSite')).split('=')[1]);
console.log('the payload holds:', Object.keys(payload).join(', '));
console.log('/account with the cookie:', /<h1 data-account[^>]*>([^<]*)<\/h1>/.exec(account)[1]);

check('a valid sign-up answers 303 to the account page', [res.status, where(res)], [303, '/account']);
check('and sets one cookie, named shelf_session', [cookies.length, pair.startsWith('shelf_session=')], [1, true]);
check('with the attributes the code asked for', attributes.map((a) => a.split('=')[0]).sort(), ['Expires', 'HttpOnly', 'Max-Age', 'Path', 'SameSite', 'Secure']);
check('lifetime and scope', [attributes.includes('Max-Age=3600'), attributes.includes('Path=/'), attributes.includes('SameSite=lax')], [true, true, true]);
check('the value is two base64url parts', /^[\w-]+\.[\w-]+$/.test(token), true);
check('and the payload names the user by id and says when it ends, not by email or password', Object.keys(payload).sort(), ['exp', 'sub']);
check('a page that asks for the session accepts the cookie', /Signed in as ada@example\.com/.test(account), true);
const duplicate = await post('/signup', { name: 'Ada', email: 'ada@example.com', password: 'correct horse' });
const weak = await post('/signup', { name: 'Bob', email: 'bob@example.com', password: 'short' });
console.log('same email again ->', duplicate.status, decodeURIComponent(where(duplicate)));
console.log('a short password ->', weak.status, decodeURIComponent(where(weak)));
check('a second sign-up with the same email goes back to the form with a message and no cookie', [duplicate.status, where(duplicate), duplicate.headers.getSetCookie().length], [303, '/signup?error=That%20email%20is%20already%20registered.', 0]);
check('so does a password under 8 characters', [weak.status, where(weak), weak.headers.getSetCookie().length], [303, '/signup?error=Use%20at%20least%208%20characters.', 0]);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node signup.mjs

POST /signup -> 303 /account
cookies set: 1  name: shelf_session
attributes: Path, Expires, Max-Age, Secure, HttpOnly, SameSite  SameSite is lax
the payload holds: sub, exp
/account with the cookie: Signed in as ada@example.com
ok   a valid sign-up answers 303 to the account page
ok   and sets one cookie, named shelf_session
ok   with the attributes the code asked for
ok   lifetime and scope
ok   the value is two base64url parts
ok   and the payload names the user by id and says when it ends, not by email or password
ok   a page that asks for the session accepts the cookie
same email again -> 303 /signup?error=That email is already registered.
a short password -> 303 /signup?error=Use at least 8 characters.
ok   a second sign-up with the same email goes back to the form with a message and no cookie
ok   so does a password under 8 characters

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

Logging in, and out

Login looks the user up and verifies the password. A wrong password and an unknown email give the same message and the same redirect, so the form cannot be used to find out which addresses have accounts. Logging out deletes the cookie. Deleting means sending the cookie again with an empty value and a date in the past, and only the browser obeys; the server has nothing to delete, because it never kept anything. The panel logs out and then presents the old token again.

Log in with a wrong and a right password, then log out

ran at build time · next build + next start · Next 16.3.1

login.mjs

const base = process.env.BASE_URL;
const ids = (html) => [...html.matchAll(/name="(\$ACTION_ID_[0-9a-f]+)"/g)].map((m) => m[1]);
async function post(path, fields, { cookie, which = 0 } = {}) {
  const page = await (await fetch(base + path, { headers: cookie ? { cookie } : {}, redirect: 'manual' })).text();
  const body = new FormData();
  body.append(ids(page)[which], '');
  for (const [name, value] of Object.entries(fields)) body.append(name, value);
  return fetch(base + path, { method: 'POST', body, redirect: 'manual', headers: { origin: base, ...(cookie ? { cookie } : {}) } });
}
const where = (res) => new URL(res.headers.get('location'), base).pathname + new URL(res.headers.get('location'), base).search;

await post('/signup', { name: 'Grace', email: 'grace@example.com', password: 'correct horse' });
const wrong = await post('/login', { email: 'grace@example.com', password: 'wrong horse' });
const unknown = await post('/login', { email: 'nobody@example.com', password: 'correct horse' });
const right = await post('/login', { email: 'grace@example.com', password: 'correct horse' });
const cookie = right.headers.getSetCookie()[0].split('; ')[0];

const out = await post('/account', {}, { cookie, which: 1 });
const cleared = out.headers.getSetCookie()[0];
const after = await fetch(base + '/account', { headers: { cookie }, redirect: 'manual' });

console.log('wrong password ->', wrong.status, decodeURIComponent(where(wrong)), ' cookies:', wrong.headers.getSetCookie().length);
console.log('unknown email  ->', unknown.status, decodeURIComponent(where(unknown)), ' cookies:', unknown.headers.getSetCookie().length);
console.log('right password ->', right.status, where(right), ' cookies:', right.headers.getSetCookie().length);
console.log('log out        ->', out.status, where(out));
console.log('cookie sent back:', cleared.split('; ')[0], '| expires in 1970:', cleared.includes('1970'));
console.log('the old token on /account afterwards:', after.status);

check('a wrong password goes back to the form with a message and no cookie', [wrong.status, where(wrong), wrong.headers.getSetCookie().length], [303, '/login?error=Wrong%20email%20or%20password.', 0]);
check('an unknown email gets exactly the same answer', [unknown.status, where(unknown), unknown.headers.getSetCookie().length], [303, '/login?error=Wrong%20email%20or%20password.', 0]);
check('the right password sets the cookie and goes to the account page', [right.status, where(right), right.headers.getSetCookie().length], [303, '/account', 1]);
check('logging out goes to the login page', [out.status, where(out)], [303, '/login']);
check('and answers with a cookie that is empty and expired', [cleared.startsWith('shelf_session=;'), cleared.includes('1970')], [true, true]);
check('but the old token still opens the page, because the server never kept a list to remove it from', after.status, 200);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node login.mjs

wrong password -> 303 /login?error=Wrong email or password.  cookies: 0
unknown email  -> 303 /login?error=Wrong email or password.  cookies: 0
right password -> 303 /account  cookies: 1
log out        -> 303 /login
cookie sent back: shelf_session= | expires in 1970: true
the old token on /account afterwards: 200
ok   a wrong password goes back to the form with a message and no cookie
ok   an unknown email gets exactly the same answer
ok   the right password sets the cookie and goes to the account page
ok   logging out goes to the login page
ok   and answers with a cookie that is empty and expired
ok   but the old token still opens the page, because the server never kept a list to remove it from

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

The last line is the cost of a stateless token. Until its exp time passes, anyone who has a copy of the cookie value is signed in, whether or not the person clicked "Sign out" in their own browser. The usual remedy is to keep the session on the server: store a random id in the cookie, and the session in a database where it can be deleted. The signing and cookie handling in this lesson stay the same. The attack side of this, and what else a real login needs (rate limiting, password reset, email checks), is beyond what this course runs.

27 Protecting routes

A page that should be private needs a check, and the question is where to put it. Next.js gives you several places, and they are not equivalent. The short answer, which this lesson demonstrates, is to check in two places for two different jobs: cheaply and early in proxy.js so strangers are sent away fast, and thoroughly in the code that touches the data, so that skipping the first check gets an attacker nothing.

Two kinds of check

An optimistic check looks only at what the request carries. Is there a session cookie? It cannot tell a good cookie from a forged one without doing the signature work and a user lookup, which the vendored authentication guide says to avoid in the proxy, since the proxy runs on every request including prefetches. An authoritative check verifies the signature, finds the user, and decides. Our authoritative check is one function, verifySession(), in app/_lib/session.js (a server-only module: it starts with import 'server-only', which makes the build fail if a client component ever imports it, see the catalogue). Every page and every action that needs a user calls it, and it redirects to the login page if there is none. This is usually called a data access layer: the check lives next to the data, in one place.

The panel signs up a user, then asks for /account three ways. The proxy's matcher is /account/:path* and it only looks for the cookie by name. The third request carries a cookie with the right name and a payload edited to claim user 1, with the old signature.

The same page, with no cookie, an edited cookie and a good one

ran at build time · next build + next start · Next 16.3.1

proxy.js

import { NextResponse } from 'next/server';

export function proxy(request) {
  const { pathname } = request.nextUrl;

  // Lesson 27: an optimistic check. It looks only for the presence of the session cookie, which
  // is cheap, and sends strangers to the login page before any page code runs. It does not
  // decide who someone is; the pages and actions do (app/_lib/session.js).
  if (pathname.startsWith('/account')) {
    if (!request.cookies.has('shelf_session')) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
    return NextResponse.next();
  }

  // A redirect to an address that has no page at all: the proxy runs before the routes.
  if (pathname === '/proxy-demo/old') {
    return NextResponse.redirect(new URL('/proxy-demo/new', request.url));
  }

  // Pass the request on, with a header added to what the page will see
  // and another added to what the browser will get.
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-from-proxy', 'added before the page ran');
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set('x-course-proxy', 'ran');
  response.headers.set('x-course-url', request.url);
  response.headers.set('x-course-runtime', process.versions && process.versions.node ? 'node' : 'other');
  return response;
}

export const config = {
  matcher: ['/proxy-demo/:path*', '/account/:path*'],
};

app/account/page.js

import { logout } from '../auth/actions.js';
import { verifySession } from '../_lib/session.js';
import { notesFor } from '../_lib/users.js';
import { addNote } from './actions.js';

export default async function Page() {
  // The page checks the session itself too: this is the check that counts.
  const user = await verifySession();
  const notes = notesFor(user.id);
  return (
    <main>
      <h1 data-account>{`Signed in as ${user.email}`}</h1>
      <ul data-notes>
        {notes.map((title) => <li key={title}>{title}</li>)}
      </ul>
      <form action={addNote}>
        <input name="title" />
        <button type="submit">Add note</button>
      </form>
      <form action={logout}>
        <button type="submit">Sign out</button>
      </form>
    </main>
  );
}

two-layers.mjs

const base = process.env.BASE_URL;
const ids = (html) => [...html.matchAll(/name="(\$ACTION_ID_[0-9a-f]+)"/g)].map((m) => m[1]);
const get = (path, cookie) => fetch(base + path, { redirect: 'manual', headers: cookie ? { cookie } : {} });
const where = (res) => new URL(res.headers.get('location'), base).pathname;

const page = await (await get('/signup')).text();
const body = new FormData();
body.append(ids(page)[0], '');
for (const [name, value] of Object.entries({ name: 'Ada', email: 'ada-27@example.com', password: 'correct horse' })) body.append(name, value);
const signedUp = await fetch(base + '/signup', { method: 'POST', body, redirect: 'manual', headers: { origin: base } });
const good = signedUp.headers.getSetCookie()[0].split('; ')[0];

const [payload, signature] = good.slice(good.indexOf('=') + 1).split('.');
const claim = JSON.parse(Buffer.from(payload, 'base64url').toString());
const forged = 'shelf_session=' + Buffer.from(JSON.stringify({ ...claim, sub: 1 })).toString('base64url') + '.' + signature;

const none = await get('/account');
const edited = await get('/account', forged);
const valid = await get('/account', good);
console.log('no cookie     :', none.status, where(none), ' cache-control:', none.headers.get('cache-control'));
console.log('edited cookie :', edited.status, where(edited), ' cache-control:', edited.headers.get('cache-control'));
console.log('good cookie   :', valid.status);

const me = async (cookie) => {
  const res = await get('/api/me', cookie);
  return [res.status, await res.json()];
};
const meNone = await me();
const meForged = await me(forged);
const meGood = await me(good);
console.log('/api/me       :', JSON.stringify([meNone[0], meForged[0], meGood[0]]), JSON.stringify(meGood[1]));

check('with no cookie the proxy sends the visitor to the login page', [none.status, where(none)], [307, '/login']);
check('and that answer carries no cache headers: no page ran', none.headers.get('cache-control'), null);
check('the page then refuses it, with the headers of a rendered response', [edited.status, where(edited), edited.headers.get('cache-control').includes('no-store')], [307, '/login', true]);
check('a good cookie opens the page', valid.status, 200);
check('a route handler outside the matcher answers a program with a status, not a redirect', [meNone[0], meForged[0], meGood[0]], [401, 401, 200]);
check('and says who, only when the session is real', [meNone[1], meGood[1].email], [{ error: 'not signed in' }, 'ada-27@example.com']);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node two-layers.mjs

no cookie     : 307 /login  cache-control: null
edited cookie : 307 /login  cache-control: private, no-cache, no-store, max-age=0, must-revalidate
good cookie   : 200
/api/me       : [401,401,200] {"id":3,"email":"ada-27@example.com"}
ok   with no cookie the proxy sends the visitor to the login page
ok   and that answer carries no cache headers: no page ran
ok   the page then refuses it, with the headers of a rendered response
ok   a good cookie opens the page
ok   a route handler outside the matcher answers a program with a status, not a redirect
ok   and says who, only when the session is real

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

Both unauthorised requests ended on the login page, but not by the same road. The first answer has no cache headers, because the proxy made it before any page code ran. The second has them, because the page ran, called verifySession() and redirected. If the second check did not exist, the edited cookie would have passed the first one and the page would have rendered for whoever the cookie claimed to be. That is the reason the proxy is not enough on its own.

A route handler is not a page: its caller is a program, which wants a status code rather than a redirect to an HTML page it cannot use. /api/me uses getSession(), which returns the user or null without redirecting, and answers 401 itself. Lesson 19 has the rest of route handlers.

Actions are public

A Server Action looks like a function you call from a form, but it is an address that accepts a POST (lesson 17 forged an id to show it). Anyone who can send a POST with the action's id can run it, whether or not your page ever showed them the form. So the action checks the session itself before it touches anything, as addNote does. The panel posts the same action four ways and then looks at what was saved.

Posting the add-note action with no cookie, an edited cookie and a good one

ran at build time · next build + next start · Next 16.3.1

app/account/actions.js

'use server';
import { verifySession } from '../_lib/session.js';
import { addNoteFor } from '../_lib/users.js';

export async function addNote(formData) {
  // An action is a public POST endpoint: it checks the session itself, whatever the proxy did.
  const user = await verifySession();
  const title = String(formData.get('title') ?? '').trim();
  if (title) addNoteFor(user.id, title);
}

actions.mjs

import { readFileSync } from 'node:fs';

const base = process.env.BASE_URL;
const ids = (html) => [...html.matchAll(/name="(\$ACTION_ID_[0-9a-f]+)"/g)].map((m) => m[1]);
const get = (path, cookie) => fetch(base + path, { redirect: 'manual', headers: cookie ? { cookie } : {} });
const where = (res) => new URL(res.headers.get('location'), base).pathname;

const signupPage = await (await get('/signup')).text();
const form = new FormData();
form.append(ids(signupPage)[0], '');
for (const [name, value] of Object.entries({ name: 'Grace', email: 'grace-27@example.com', password: 'correct horse' })) form.append(name, value);
const signedUp = await fetch(base + '/signup', { method: 'POST', body: form, redirect: 'manual', headers: { origin: base } });
const good = signedUp.headers.getSetCookie()[0].split('; ')[0];
const [payload, signature] = good.slice(good.indexOf('=') + 1).split('.');
const forged = 'shelf_session=' + Buffer.from(JSON.stringify({ ...JSON.parse(Buffer.from(payload, 'base64url').toString()), sub: 1 })).toString('base64url') + '.' + signature;

const [addNoteId] = ids(await (await get('/account', good)).text());
const addNote = (title, cookie) => {
  const body = new FormData();
  body.append(addNoteId, '');
  body.append('title', title);
  return fetch(base + '/account', { method: 'POST', body, redirect: 'manual', headers: { origin: base, ...(cookie ? { cookie } : {}) } });
};

const anonymous = await addNote('anonymous note');
const edited = await addNote('edited-cookie note', forged);
const real = await addNote('real note', good);
const listed = [...(await (await get('/account', good)).text()).matchAll(/<li>([^<]*)<\/li>/g)].map((m) => m[1]);
const docs = readFileSync(process.env.APP_DIR + '/node_modules/next/dist/docs/01-app/02-guides/authentication.md', 'utf8');

console.log('no cookie     ->', anonymous.status, where(anonymous));
console.log('edited cookie ->', edited.status, where(edited));
console.log('good cookie   ->', real.status);
console.log('notes saved   :', JSON.stringify(listed));

check('with no cookie the proxy stops the POST before the action', [anonymous.status, where(anonymous)], [307, '/login']);
check('with an edited cookie the proxy lets it through, and the action\u2019s own check refuses it', [edited.status, where(edited)], [303, '/login']);
check('with a good cookie the action runs and the page comes back', real.status, 200);
check('only the note from the good cookie was saved', listed, ['real note']);
check('the vendored guide says the proxy should only read the session from the cookie', docs.includes('only read the session from the cookie'), true);
check('and warns that layouts do not re-render on navigation, so a check there can be skipped', docs.includes('re-render on navigation'), true);
check('that a layout does not control whether the rest of the route renders', docs.includes('does not control whether the rest of the route renders'), true);
check('and that static routes are the ones to protect with the proxy', docs.includes('protect static routes'), true);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node actions.mjs

no cookie     -> 307 /login
edited cookie -> 303 /login
good cookie   -> 200
notes saved   : ["real note"]
ok   with no cookie the proxy stops the POST before the action
ok   with an edited cookie the proxy lets it through, and the action’s own check refuses it
ok   with a good cookie the action runs and the page comes back
ok   only the note from the good cookie was saved
ok   the vendored guide says the proxy should only read the session from the cookie
ok   and warns that layouts do not re-render on navigation, so a check there can be skipped
ok   that a layout does not control whether the rest of the route renders
ok   and that static routes are the ones to protect with the proxy

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

The proxy caught the request with no cookie. The edited cookie got through the proxy and was stopped by the action, with a different redirect status because the action, not the proxy, made it. Nothing was saved except what the good cookie was allowed to save.

Where not to check

It is tempting to put the check in a layout, once, so every page under it is covered. Layouts are kept when a person moves between two pages under them, so the check does not run again on each move; the vendored guide says so (the panel above found the sentence in it). A layout also does not control whether the rest of the route renders, and the guide says that too. The check belongs in the page, the action and the handler: each place that returns data.

One more rule from the same guide, checked in the panel above: a page that was prerendered at build time is the same for everyone, so a data check in it would run once, at build time, and the guide's answer for static routes is the proxy. The pages in this lesson read cookies, which makes them dynamic and rendered per request (lesson 11), so their checks do run for every visitor.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.