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.)
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.
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.
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.
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.
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.
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.