cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Authentication

Sign people up and in with Next.js: hash passwords with scrypt, keep a session in a signed httpOnly cookie, read it in Server Components, and sign out.

By the end, you can
  • 01
    Store passwords safelyHash each password with a random salt and a slow hash, compare in constant time, and give one answer for every failed sign-in.
  • 02
    Keep a sessionSign a session into an httpOnly, secure, sameSite cookie from a Server Action, read it in Server Components, and pass on only what's needed.
  • 03
    Choose how sessions endSign out, compare signed tokens with database sessions, and know what Auth.js, Better Auth and Clerk do for you.

An account page that knows who you are

The tea shop wants accounts. Asha should sign in once, then see her own orders on /account on every visit, until she signs out. That takes three parts:

  • Authentication: checking that the person signing in is who they say they are. Here, Asha proves it with her password.
  • A session: what the server gives her afterwards, so she doesn't send her password with every request. Here it's a cookie the server signs, which the browser sends back each time.
  • Signing out: ending the session.

Deciding what a signed-in person may do, like whether Asha can see an order, is authorization. It has a lesson of its own, next. Here is the shop with accounts, a real Next.js 16.4.3 project, built with next build and run with next start. The recorder sent its forms the way a browser does before the page's JavaScript loads, asked for pages with and without a session cookie, then signed in through the form in real Chrome:

The tea shop, with accountsNext.js
app/account/sign-in/sign-up/lib/
app/sign-in/actions.ts
"use server"; import { redirect } from "next/navigation";import { z } from "zod";import { NO_USER_HASH, verifyPassword } from "@/lib/password";import { createSession } from "@/lib/session";import { findUserByEmail } from "@/lib/users"; const credentials = z.object({ email: z.email(), password: z.string().min(1).max(200) }); export type SignInState = { error: string; email: string }; export async function signIn(previous: SignInState, formData: FormData): Promise<SignInState> {  const email = String(formData.get("email") ?? "").trim().toLowerCase();  const result = credentials.safeParse({ email, password: formData.get("password") });  if (!result.success) return { error: "Write your email and your password.", email };  const user = await findUserByEmail(result.data.email);  const matches = await verifyPassword(result.data.password, user?.passwordHash ?? NO_USER_HASH);  if (!user || !matches) return { error: "Wrong email or password.", email };  await createSession(user.id);  redirect("/account");}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/accountTea shop
Teas Account
Your account

Signed in as Asha

Sign out
  • 2 boxes of Mint tea
  • 3 boxes of Green tea
Focus
nothing: the page itself
Scrolled
0 px down
Announced to screen readers
Your account

Requests the page made during this step (4), without the files it loaded

  1. POST/sign-in200text/x-component
    next-router-state-tree
    %5B%22%22%2C%7B%22children%22%3A%5B%22sign-in%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C530%5D
    next-action
    60025de2f216a3c2d84554f058d59bd401d726028a
  2. GET/200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /account
  3. GET/account200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /account/__PAGE__
    next-url
    /account
  4. GET/200text/x-component
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /__PAGE__
    next-url
    /account

The Steps tab opens on the last step in Chrome: after Asha typed her email and her password and clicked Sign in, the page is /account, with Signed in as Asha and her two orders. Pick step 4: her first try, with a wrong password, got Wrong email or password., on the same page.

app/sign-in/actions.ts, the file shown first, is the Server Action the form calls. Line 17 finds the user by email. Line 18 checks the password. Line 19 gives one answer for every failure. Line 20 starts the session, and line 21 sends Asha to her account. Line 18 relies on how the password was stored, and line 20 on what a session cookie holds.

Three rows between the browser, on the left, and the server, on the right. First, the browser posts the email and password to /sign-in, and the signIn action finds that the password matches. Second, createSession on the server answers with set-cookie, and the browser keeps the session cookie. Third, the browser asks for /account with the cookie, and readSession on the server finds user u1, Asha.the browserthe serverPOST /sign-in: email, passwordsignIn: the password matcheskeeps the session cookiecreateSession: set-cookieGET /account + the cookiereadSession: u1, Asha
The password travels once. After that, the session cookie is what tells the server who's asking: the browser sends it with every request, and readSession checks it each time.
Real-life analogyA festival wristband

At a festival gate, you show your ticket and your ID once, and staff put a sealed wristband on your wrist. From then on, every stage only looks at the wristband. It stops working on the last day, and you cut it off when you go home.

In real life: At the gate, you show your ticket and your ID once
In Next.js: Sign-in: the email and the password, checked once
In real life: You get a wristband with a seal staff can recognize
In Next.js: The server sets a signed session cookie
In real life: Every stage checks the wristband, not your ID
In Next.js: Every request sends the cookie, and the server checks it
In real life: The wristband stops working when the festival ends
In Next.js: The session expires after 7 days
In real life: You cut it off when you leave
In Next.js: Signing out deletes the cookie
The main idea

Authentication checks who someone is once, then a session remembers it. Store each password as a salted, slow hash. When it matches, a Server Action sets a session cookie the server signs: httpOnly, secure, sameSite lax, with an expiry. Server Components read that cookie on each request, and signing out ends it.

Storing passwords: a salt and a slow hash

A shop must never keep the passwords themselves. Databases leak, and people reuse passwords: a leaked table would open their email and their bank too. Instead, the shop keeps a hash: a fixed-length fingerprint of the password that can't be turned back into it. To check a password, it hashes what was typed and compares the two fingerprints. Each row is one way to store a password, and what a leaked table gives away:

Three ways to store a password
What the table holdsWhat a leak gives awayUse it?
The password as typedEvery password, ready to try on other sites.Never.
One fast hash, like SHA-256, no saltThe same hash for the same password, and billions of guesses a second on a graphics card.Never for passwords.
scrypt with a random salt for each userA different hash for every user, and each guess costs real time and memory.Yes: Node.js has it built in. bcrypt and Argon2 are good too.

Two things make a password hash good. A salt, a random value made for each user and stored beside the hash, so the same password gives a different hash for every user. And a slow hash: one built so each guess costs time and memory. scrypt is one, and it's built into Node.js, in node:crypto:

lib/password.ts: hash with a salt, compare in constant timeTypeScript
import "server-only";import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; type Cost = { N: number; r: number; p: number }; // OWASP's baseline for scrypt: N = 2^17 (128 MiB of memory for each hash), r = 8, p = 1.const POLICY: Cost = { N: 2 ** 17, r: 8, p: 1 }; function scryptAsync(password: string, salt: Buffer, cost: Cost) {  // scrypt needs 128 × N × r bytes; Node.js refuses more than 32 MiB unless maxmem says so.  const options = { ...cost, maxmem: 256 * 1024 * 1024 };  return new Promise<Buffer>((resolve, reject) => {    scrypt(password, salt, 64, options, (error, key) => (error ? reject(error) : resolve(key)));  });} // Stored as "scrypt$N$r$p$salt$hash": the cost goes with each hash, so a later, higher cost// can still check the hashes made today.export async function hashPassword(password: string) {  const salt = randomBytes(16);  const hash = await scryptAsync(password, salt, POLICY);  return ["scrypt", POLICY.N, POLICY.r, POLICY.p, salt.toString("hex"), hash.toString("hex")].join("$");} export async function verifyPassword(password: string, stored: string) {  const [name, n, r, p, salt = "", hash = ""] = stored.split("$");  const cost = { N: Number(n), r: Number(r), p: Number(p) };  // Only costs this shop would use: a power of two from 2^14 to 2^17, r = 8, p from 1 to 4.  const known = Number.isInteger(Math.log2(cost.N)) && cost.N >= 2 ** 14 && cost.N <= 2 ** 17 && cost.r === 8 && cost.p >= 1 && cost.p <= 4;  if (name !== "scrypt" || !known || !/^[0-9a-f]{32}$/.test(salt) || !/^[0-9a-f]{128}$/.test(hash)) return false;  const expected = Buffer.from(hash, "hex");  const actual = await scryptAsync(password, Buffer.from(salt, "hex"), cost);  return actual.length === expected.length && timingSafeEqual(actual, expected);} // Checked when no user has the email, at the same cost, so that answer takes as long as a wrong password.export const NO_USER_HASH = ["scrypt", POLICY.N, POLICY.r, POLICY.p, "0".repeat(32), "0".repeat(128)].join("$"); 
  • Line 1, import "server-only", makes the build fail if a Client Component imports this file (Server-only code).
  • Line 7 is the cost: OWASP's Password Storage Cheat Sheet asks scrypt for N of 217 at least, with r 8 and p 1. That takes 128 MiB of memory for every guess. Node.js refuses more than 32 MiB unless asked, so line 11 raises maxmem.
  • Line 20 makes a new salt: 16 random bytes. Line 21 hashes the password with it into 64 bytes. Line 22 stores both with the cost, as scrypt$N$r$p$salt$hash. The cost is stored because it will change: when computers get faster, the shop raises POLICY, and the hashes made today still verify with the cost written beside them, until each user's next sign-in hashes their password again at the new cost.
  • verifyPassword splits the stored string on line 26. Lines 27 to 30 refuse anything this shop wouldn't have written: another algorithm, a cost outside the range on line 28, or a salt or hash of the wrong length. A tampered row can't ask for a cost that takes the server down.
  • Line 32 hashes what was typed with the same salt and cost, and line 33 compares. timingSafeEqual takes the same time however many bytes match, so nobody can learn the hash by timing the answers. It throws if the two lengths differ, so line 33 checks the lengths first.

The recording also created an account for Ben through /sign-up, whose action calls hashPassword before it saves him. The stand-in users table prints each row it saves. Open the example's Terminal tab, under $ next start: Ben's row holds a passwordHash that starts scrypt$131072$8$1$, then 32 hexadecimal characters of salt, a $, and 128 of hash. His password, lapsang-42-tea, appears nowhere.

app/sign-up/actions.ts: the password is hashed before it's savedTypeScript
"use server"; import { redirect } from "next/navigation";import { z } from "zod";import { hashPassword } from "@/lib/password";import { createSession } from "@/lib/session";import { createUser, findUserByEmail } from "@/lib/users"; const account = z.object({  name: z.string().trim().min(1).max(50),  email: z.email(),  password: z.string().min(8, "Use 8 characters or more.").max(200),}); export type SignUpState = { error: string }; export async function signUp(previous: SignUpState, formData: FormData): Promise<SignUpState> {  const result = account.safeParse({    name: formData.get("name"),    email: String(formData.get("email") ?? "").trim().toLowerCase(),    password: formData.get("password"),  });  if (!result.success) return { error: z.flattenError(result.error).fieldErrors.password?.[0] ?? "Check the fields." };  if (await findUserByEmail(result.data.email)) return { error: "That email already has an account. Sign in instead." };  const user = await createUser(result.data.name, result.data.email, await hashPassword(result.data.password));  await createSession(user.id);  redirect("/account");} 

Line 25 hashes the password and saves the user, and line 26 signs Ben in. Line 24 is a choice every sign-up form has to make. When the email already has an account, the form has to say so somehow, and that tells anyone typing an email whether it has an account. The recording answers That email already has an account. Sign in instead. Many sites instead always answer "check your email", and send the message to the address, so only its owner learns the answer. The sign-in form, below, has no such excuse.

Line 24 and line 25 are also two separate steps. Two sign-ups with the same email at the same moment can both pass line 24 before either one saves. A real users table needs a unique constraint on the email, stored trimmed and lowercased as line 20 makes it, and the insert must catch the database's conflict error and answer like line 24, or with the same "check your email" answer if the form hides which emails have accounts. The stand-in table here has no constraint.

bcrypt and Argon2

Argon2 and bcrypt are slow password hashes too, from npm packages. OWASP's cheat sheet puts Argon2id first, scrypt next when Argon2id isn't available, and bcrypt for older systems. Whichever you use, what matters is a salt, a cost you can raise, and a constant-time compare. A fast hash like SHA-256, even with a salt, is the wrong tool for passwords.

A session in a signed cookie

After the password matches, the server needs to remember who Asha is. The shop puts that in a cookie. Anyone can make a cookie, so the server signs it: proxy.ts signed a staff cookie with an HMAC, a code made from the value and a secret only the server knows. This shop uses a standard format for signed values, a JWT (JSON Web Token), made with the npm package jose, 6.2.14 here:

lib/session.ts: sign a token, set it as an httpOnly cookieTypeScript
import "server-only";import { SignJWT, jwtVerify } from "jose";import { cookies } from "next/headers"; // 32 random bytes or more, in base64: make them with `openssl rand -base64 32`.const secret = process.env.SESSION_SECRET ?? "";const key = Buffer.from(secret, "base64");if (!/^[A-Za-z0-9+/]+={0,2}$/.test(secret) || key.length < 32) {  throw new Error("Set SESSION_SECRET to a random secret: openssl rand -base64 32");} export async function createSession(userId: string) {  const token = await new SignJWT({ userId })    .setProtectedHeader({ alg: "HS256" })    .setIssuedAt()    .setExpirationTime("7d")    .sign(key);  (await cookies()).set("session", token, {    httpOnly: true,    secure: true,    sameSite: "lax",    path: "/",    maxAge: 60 * 60 * 24 * 7,  });} export async function readSession() {  const token = (await cookies()).get("session")?.value;  if (!token) return null;  try {    const { payload } = await jwtVerify(token, key, { algorithms: ["HS256"] });    return typeof payload.userId === "string" ? { userId: payload.userId } : null;  } catch {    return null;  }} export async function deleteSession() {  (await cookies()).delete("session");} 
  • Lines 6 to 10 read SESSION_SECRET from the .env file and decode it from base64, then stop the server unless it's base64 that decodes to 32 bytes or more. Counting characters wouldn't do: 32 typed letters hold far fewer than 256 random bits. Make a real one with openssl rand -base64 32, and keep it out of your code (environment variables). The example's .env holds one made that way.
  • Lines 13 to 17 make the token: a payload of { userId }, the algorithm HS256 (an HMAC with SHA-256), the time it was made, an expiry 7 days later, and the signature.
  • Lines 18 to 24 set it as the session cookie with cookies() from next/headers, which can set cookies in a Server Action or a Route Handler.
  • readSession, on lines 27 to 36, checks the signature and the expiry with jwtVerify on line 31. Any failure lands in the catch, and the answer is null: no session.

Each option on lines 19 to 23 protects the cookie from something. Each row is one option:

The session cookie's options
OptionWhat it doesWhat it stops
httpOnly: trueThe page's JavaScript can't read the cookie.An injected script copying the session away. It can still send requests that carry the cookie.
secure: trueThe browser sends it only over HTTPS. Browsers also allow it on localhost while you develop.Someone on the same network reading it.
sameSite: "lax"Following a link from another site to the shop (a top-level GET) carries it. Another site's images, fetches, frames and posted forms don't.Most cross-site request forgery: another site making the visitor's browser post to the shop.
path: "/"Every page of the shop gets it.A session that only some pages can see.
maxAge: 60 * 60 * 24 * 7The browser deletes it after 7 days.A session that lives forever on a shared computer.

Two of them need a closer look. httpOnly stops scripts from reading the cookie. It doesn't stop a script someone injected into the page from sending requests the browser attaches the cookie to: that script can still act as Asha while the page is open. Keeping scripts out in the first place is the job of escaping and a Content Security Policy (Security headers and Content Security Policy). And sameSite: "lax" still sends the cookie when another site links to the shop and the visitor follows it: a top-level GET. It leaves the cookie off another site's images, fetches and frames, and off forms another site posts to the shop.

Server Actions get one more check from Next.js itself: it compares the Origin header of an action's request with the Host header (or X-Forwarded-Host), and aborts the action when they differ. Behind a proxy whose public host differs on purpose, serverActions.allowedOrigins in next.config.ts lists the extra hosts; don't add hosts to it for any other reason. The docs also say a request with no Origin at all is let through, with a warning. Neither SameSite nor the Origin check decides who may do what: each action still checks the session, and Securing Server Actions goes further.

In the recording, the answer to the right password carried this header. Pick Sign in with the right password in the Network tab to see it whole:

The set-cookie header the sign-in answered with (the token shortened)Text
set-cookie: session=eyJhbGci…; Path=/; Expires=Fri, 06 Nov 2026 06:32:54 GMT; Max-Age=604800; Secure; HttpOnly; SameSite=lax

Max-Age=604800 is 7 days in seconds, and Expires is the same moment as a date, for older browsers. A JWT has three parts, separated by dots:

Asha's session token in three parts, separated by dots: the header, alg HS256; the payload, userId u1 and its expiry; and the signature, an HMAC of both made with the shop's secret. Anyone can read the first two; only the server can make the third. Below, an edited payload, userId u2, leads to jwtVerify throwing.alg: HS256.userId: u1, exp.HMAC of bothanyone can read these twoonly the server can make ituserId: u2, expjwtVerify throwssomeone edited the payloadnot signed in
A JWT is header.payload.signature. The first two parts are only encoded, so anyone with the cookie can read them. The signature is made from both with SESSION_SECRET: change one character of the payload, and it no longer matches.

The header and the payload are only encoded, not hidden: anyone holding the cookie can read userId in it. So keep the payload to an id, and never put a secret in it. What nobody can do without SESSION_SECRET is change it. Two more recordings prove it: with a cookie someone edited (the payload says u2, the signature is the old one) and with a cookie that expired in 2020. Both pages say You're not signed in. Libraries can also encrypt the payload, so nobody can read it: jose's EncryptJWT and the iron-session package do that.

Signing in with a Server Action

The form and its action use what Forms in Next.js taught: useActionState, and Zod to check the fields on the server. Here is the form, a Client Component:

app/sign-in/sign-in-form.tsxTSX
"use client"; import { useActionState } from "react";import { signIn, type SignInState } from "./actions"; const empty: SignInState = { error: "", email: "" }; export default function SignInForm() {  const [state, formAction, isPending] = useActionState(signIn, empty);  return (    <form action={formAction}>      <label htmlFor="email">Email</label>      <input id="email" name="email" type="email" autoComplete="email" defaultValue={state.email} />      <label htmlFor="password">Password</label>      <input id="password" name="password" type="password" autoComplete="current-password" />      <button disabled={isPending}>{isPending ? "Signing in…" : "Sign in"}</button>      <p aria-live="polite">{state.error}</p>    </form>  );} 

Line 9 calls useActionState, which returns three values: the action's last answer, state; the function the form calls, formAction; and isPending, true while the action runs, which line 16 uses to say Signing in…. Line 13 puts the email back with defaultValue, so a wrong password doesn't make Asha type it again. Line 15, the password, has no defaultValue on purpose: the action never sends a password back to the page.

Now the action, shown first in the example above. Line by line:

  • Line 14 trims the email and makes it lowercase, so Asha@Example.com finds the same account.
  • Line 15 checks both fields with the schema on line 9. Line 16 answers when they're malformed. A Server Action is a public endpoint, so anything can arrive here, not only what the form sends.
  • Line 18 runs verifyPassword even when line 17 found no user, against NO_USER_HASH. A missing user then takes as long as a wrong password, so timing the answer doesn't tell anyone which emails have accounts.
  • Line 19 gives the same answer for both: Wrong email or password.
  • Lines 20 and 21 run only for the right password: start the session, and redirect to /account.

Check it in the example's Browser tab. Sign in with a wrong password and Sign in with an email that has no account both show Wrong email or password., with the email still in its field. Sign in with the right password has no page: the answer was 303, with location: /account and the set-cookie above. That's the page without JavaScript; a browser follows the 303 to /account (Redirects explains the status codes).

With JavaScript, it looks different. Open the Steps tab and pick the last step. Its one request that isn't a prefetch is a POST to /sign-in with a next-action header: the Server Action. It answered 200, as text/x-component, an RSC payload, and the router moved to /account with no new page load.

Guessing passwords

Nothing here stops someone from trying a thousand passwords for Asha. Real sign-in forms limit how many tries an address or an account gets in a while, and some lock an account, or ask for a code, after too many. That's rate limiting, and Securing Server Actions builds it.

Reading the session in Server Components

The account page reads the session on every request. A cookie only exists when a request comes in, so with Cache Components the part that reads it has to wait inside <Suspense> (Request-time APIs):

app/account/page.tsxTSX
import { Suspense } from "react";import Link from "next/link";import { getCurrentUser } from "@/lib/current-user";import { getMyOrders } from "@/lib/orders";import AccountMenu from "./account-menu"; async function Account() {  const user = await getCurrentUser();  if (!user) return <p>You're not signed in. <Link href="/sign-in">Sign in</Link></p>;  const orders = await getMyOrders();  return (    <>      <AccountMenu name={user.name} />      <ul>        {orders.map((order) => (          <li key={order.id}>{order.boxes} boxes of {order.tea}</li>        ))}      </ul>    </>  );} export default function AccountPage() {  return (    <main>      <h1>Your account</h1>      <Suspense fallback={<p>Loading your account…</p>}>        <Account />      </Suspense>    </main>  );} 

Line 8 asks getCurrentUser for the signed-in user. Line 9 answers anyone without a valid session. Line 13 hands the Client Component AccountMenu one thing: the name. Lines 27 to 29 put all of it inside Suspense, so the heading is prerendered and the account streams in. The example's Terminal tab says ◐ /account, and its Network tab shows Cache-Control: private, no-cache, no-store… on every /account answer: nobody may keep a copy of someone's account page.

lib/current-user.ts: the session, turned into what pages needTypeScript
import "server-only";import { cache } from "react";import { readSession } from "./session";import { findUserById } from "./users"; // React's cache() runs it once per React render pass, however many components ask. Never "use cache":// it can't read cookies, and its entries are shared between visitors.export const getCurrentUser = cache(async () => {  const session = await readSession();  if (!session) return null;  const user = await findUserById(session.userId);  // Only what pages show: never the email or the password hash.  return user ? { id: user.id, name: user.name } : null;}); 

Line 8 wraps the function in React's cache(): the account page asks for the user, and so does getMyOrders, below, and cache() makes the two share one answer within one React render pass, so the cookie is checked and the user looked up once for that render. It never outlives the render: one request can render more than once, and Server Actions and Route Handlers get no sharing at all. "use cache" would be wrong here: it can't read cookies, and what it stores is shared between visitors. Line 9 reads the session, and line 11 finds the user. Line 13 returns only id and name. That matters, because whatever a Server Component passes to a Client Component is sent to the browser (Composing Server and Client Components). Here are two versions of the account page. The recorder asked for each one's RSC payload with Asha's cookie:

Playground: what does AccountMenu's prop send?
The account page, with this version of app/account/page.tsxNext.js
app/account/sign-in/sign-up/lib/
app/account/page.tsx
import { Suspense } from "react";import Link from "next/link";import { readSession } from "@/lib/session";import { findUserById } from "@/lib/users";import { getMyOrders } from "@/lib/orders";import AccountMenu from "./account-menu"; async function Account() {  const session = await readSession();  const user = session && (await findUserById(session.userId));  if (!user) return <p>You're not signed in. <Link href="/sign-in">Sign in</Link></p>;  const orders = await getMyOrders();  return (    <>      <AccountMenu user={user} />      <ul>        {orders.map((order) => (          <li key={order.id}>{order.boxes} boxes of {order.tea}</li>        ))}      </ul>    </>  );} export default function AccountPage() {  return (    <main>      <h1>Your account</h1>      <Suspense fallback={<p>Loading your account…</p>}>        <Account />      </Suspense>    </main>  );}
Recorded from Next.js 16.4.3

GET /account?_rsc=200

Sent with the request
rsc 1
cookie session=eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MSIsImlhdCI6MTc5MzMxODQwMCwiZXhwIjo0MTAyNDQ0ODAwfQ.sY9GVxj1-v3mOJvS4ev9s8MSmkxEorTL7e01C0TNkVQ
Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
Cache-Control private, no-cache, no-store, max-age=0, must-revalidate
Content-Type text/x-component

The body that arrived, as it arrived: the RSC payload, one row per line

2:"$Sreact.fragment"
3:"/_next/static/chunks/3oh00mkbkq8u6.js"
4:"/_next/static/chunks/2txhp0n1jopjl.js"
5:I[22016,["$3","$4"],""]
6:I[39756,["$3"],"default"]
7:I[37457,["$3"],"default"]
c:"$Sreact.suspense"
e:I[97367,["$3"],"OutletBoundary"]
11:"ViewportBoundary"
12:I[97367,["$3"],"$11"]
14:"MetadataBoundary"
15:I[97367,["$3"],"$14"]
1:X
8:X
a:X
10:X
b:[["children",{"s":"__PAGE__","h":162,"d":{"r":["$","$2","c",{"children":[["$","main",null,{"children":[["$","h1",null,{"children":"Your account"}],["$","$c",null,{"fallback":["$","p",null,{"children":"Loading your account…"}],"children":"$Ld"}]]}],[["$","script","script-0",{"src":"/_next/static/chunks/2txhp0n1jopjl.js","async":true,"nonce":"$undefined"}]],["$","$Le",null,{"children":["$","$c",null,{"name":"Next.MetadataOutlet","children":"$@f"}]}]]}],"p":false,"v":"$10"}}]]
9:[["children",{"s":"account","h":98,"d":{"r":["$","$2","c",{"children":[null,["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":false,"v":"$a"},"c":"$Qb"}]]
17:X
18:X
1b:R
0:{"q":"","i":false,"S":true,"r":"$1","b":"xhOe5Ff1td_-lGHTmHiZd","t":{"t":{"s":"","h":82,"d":{"r":["$","$2","c",{"children":[[["$","script","script-0",{"src":"/_next/static/chunks/3oh00mkbkq8u6.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":[["$","nav",null,{"children":[["$","$L5",null,{"href":"/","children":"Teas"}]," ",["$","$L5",null,{"href":"/account","children":"Account"}]]}],["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]]}]}]]}],"p":false,"v":"$8"},"c":"$Q9"},"h":{"r":["$","$2","h",{"children":[null,["$","$L12","Y_zskBVF2yvP0qTH89FSLv",{"children":"$L13"}],["$","$L15","Y_zskBVF2yvP0qTH89FSLm",{"children":[["$","div",null,{"hidden":true,"children":["$","$c",null,{"name":"Next.Metadata","children":"$L16"}]}],null]}]]}],"p":false,"v":"$17"}},"s":"$18","l":"$@19","a":"$@1a","p":"$1b"}
a:C
18:300
13:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
f:null
16:[["$","title","0",{"children":"Tea shop"}]]
18:C
17:C
1:C
10:C
8:C
19:3657
1a:3634
1c:I[11988,["$3","$4"],"default"]
d:[["$","$L1c",null,{"user":{"id":"u1","name":"Asha","email":"asha@example.com","passwordHash":"scrypt$131072$8$1$5f1c2a9e7b3d4c6a8e0f1b2c3d4e5f60$19c2186aa17ba6b6430ef7f372557e29732a232d183f6281589e638c830fa2202aad452eb0f9e265fcfd9b4dfba4c4a0b0a53a7b26e1c3112b61f11ceede0da5"}}],["$","ul",null,{"children":[["$","li","1",{"children":[2," boxes of ","Mint tea"]}],["$","li","2",{"children":[3," boxes of ","Green tea"]}]]}]]
1b:o1,~1b:o4e8,2:"$Sreact.fragment"
3:"/_next/static/chunks/3oh00mkbkq8u6.js"
4:"/_next/static/chunks/2txhp0n1jopjl.js"
5:I[22016,["$3","$4"],""]
6:I[39756,["$3"],"default"]
7:I[37457,["$3"],"default"]
c:"$Sreact.suspense"
e:I[97367,["$3"],"OutletBoundary"]
11:"ViewportBoundary"
12:I[97367,["$3"],"$11"]
14:"MetadataBoundary"
15:I[97367,["$3"],"$14"]
1:X
8:X
a:X
10:X
b:[["children",{"s":"__PAGE__","h":162,"d":{"r":["$","$2","c",{"children":[["$","main",null,{"children":[["$","h1",null,{"children":"Your account"}],["$","$c",null,{"fallback":["$","p",null,{"children":"Loading your account…"}],"children":"$Ld"}]]}],[["$","script","script-0",{"src":"/_next/static/chunks/2txhp0n1jopjl.js","async":true,"nonce":"$undefined"}]],["$","$Le",null,{"children":["$","$c",null,{"name":"Next.MetadataOutlet","children":"$@f"}]}]]}],"p":false,"v":"$10"}}]]
9:[["children",{"s":"account","h":98,"d":{"r":["$","$2","c",{"children":[null,["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":false,"v":"$a"},"c":"$Qb"}]]
17:X
18:X
1b:o87b,0:{"q":"","i":false,"S":true,"r":"$1","b":"xhOe5Ff1td_-lGHTmHiZd","t":{"t":{"s":"","h":82,"d":{"r":["$","$2","c",{"children":[[["$","script","script-0",{"src":"/_next/static/chunks/3oh00mkbkq8u6.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":[["$","nav",null,{"children":[["$","$L5",null,{"href":"/","children":"Teas"}]," ",["$","$L5",null,{"href":"/account","children":"Account"}]]}],["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]]}]}]]}],"p":false,"v":"$8"},"c":"$Q9"},"h":{"r":["$","$2","h",{"children":[null,["$","$L12","Y_zskBVF2yvP0qTH89FSLv",{"children":"$L13"}],["$","$L15","Y_zskBVF2yvP0qTH89FSLm",{"children":[["$","div",null,{"hidden":true,"children":["$","$c",null,{"name":"Next.Metadata","children":"$L16"}]}],null]}]]}],"p":false,"v":"$17"}},"s":"$18","a":"$@19","w":"$@1a"}
1b:o8a,a:C
18:300
13:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
1b:o36,f:null
16:[["$","title","0",{"children":"Tea shop"}]]
1b:oa,17:0
10:0
1b:o11,1a:false
19:3619
1b:o17,18:C
17:C
1:C
10:C
8:C
1b:C
Try it yourself
The whole row

Lines 9 and 10 read the session and the whole users-table row, and line 15 passes it as user. The menu shows only user.name, but the payload carries the whole row: look for passwordHash and asha@example.com in the Network tab.

Both versions were built and run with Next.js 16.4.3. The Network tab shows /account's RSC payload, asked for with Asha's session cookie and rsc: 1, the way the router asks. Reset goes back to the first version.

The first page passes the row from the users table to the menu, which shows only user.name. Its payload carries "email":"asha@example.com" and "passwordHash":"scrypt$131072$8$1$5f1c2a9e…" anyway: the props are sent whole. The page looks the same in both versions, so nothing on screen warns you. The second page's payload has neither. On a full page load, the same payload arrives inside the HTML response, in inline scripts, as Composing Server and Client Components showed.

Data for the signed-in user

The orders follow one more rule: the user's id comes from the session, never from the request.

lib/orders.ts: cached by the user's id, which only the server picksTypeScript
import "server-only";import { cacheLife } from "next/cache";import { getCurrentUser } from "./current-user"; const orders = [  { id: 1, userId: "u1", tea: "Mint tea", boxes: 2 },  { id: 2, userId: "u1", tea: "Green tea", boxes: 3 },]; export async function getMyOrders() {  const user = await getCurrentUser();  return user ? ordersFor(user.id) : [];} // Not exported: no other code can ask for someone else's orders.async function ordersFor(userId: string) {  "use cache";  cacheLife("minutes");  return orders.filter((order) => order.userId === userId);} 

getMyOrders, on lines 10 to 13, is the only function other files can call. It finds the user itself, and passes the id to ordersFor, whose "use cache" on line 17 keeps each user's orders under their id. ordersFor isn't exported, so no other code can pass someone else's id. That's the pattern Private and remote caches pointed to. Next.js's guide to authentication with Cache Components also puts its user lookup in "use cache: private", so the router can prefetch the signed-in page. Plain "use cache" keyed by the verified user id stores that user's orders in the server's shared cache: fine when your policy allows it; otherwise use "use cache: private" (Private and remote caches) or don't cache.

One shop here, many on a platform

This example is a single shop: one users table, one secret, one kind of session. In a multi-tenant app like Teahub, a user signs in to one shop. Find that shop from the host you trust, as that lesson does, put its id in the session when Asha signs in, and on every request check that the session's shop is the request's shop. Then filter users, sessions and orders by the shop and the user. A cookie copied from Asha's Tea Room to Ben's address must open nothing there.

Signing out, and taking a session back

Signing out is a Server Action too: AccountMenu's form calls signOut, in app/account/actions.ts. It calls deleteSession, which deletes the cookie, then redirect("/"). Deleting a cookie means setting it again, empty, with an expiry in the past: the recorded answer carries set-cookie: session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT, and the browser drops it.

That ends the session in one browser. But what if someone else has a copy of the cookie: a stolen one, or a second device? Here are two versions of lib/session.ts. In both, the recorder opened /account with a copy of Asha's cookie, signed out with it, then sent the same copy again:

Playground: a copy of the cookie, after Sign out
The tea shop, with this version of lib/session.tsNext.js
app/account/sign-in/sign-up/lib/
lib/session.ts
import "server-only";import { SignJWT, jwtVerify } from "jose";import { cookies } from "next/headers"; // 32 random bytes or more, in base64: make them with `openssl rand -base64 32`.const secret = process.env.SESSION_SECRET ?? "";const key = Buffer.from(secret, "base64");if (!/^[A-Za-z0-9+/]+={0,2}$/.test(secret) || key.length < 32) {  throw new Error("Set SESSION_SECRET to a random secret: openssl rand -base64 32");} export async function createSession(userId: string) {  const token = await new SignJWT({ userId })    .setProtectedHeader({ alg: "HS256" })    .setIssuedAt()    .setExpirationTime("7d")    .sign(key);  (await cookies()).set("session", token, {    httpOnly: true,    secure: true,    sameSite: "lax",    path: "/",    maxAge: 60 * 60 * 24 * 7,  });} export async function readSession() {  const token = (await cookies()).get("session")?.value;  if (!token) return null;  try {    const { payload } = await jwtVerify(token, key, { algorithms: ["HS256"] });    return typeof payload.userId === "string" ? { userId: payload.userId } : null;  } catch {    return null;  }} export async function deleteSession() {  (await cookies()).delete("session");}
Recorded from Next.js 16.4.3
localhost:3000/accountTea shop
Teas
Your account

Signed in as Asha

Sign out
  • 2 boxes of Mint tea
  • 3 boxes of Green tea

Sent with cookie: session=eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MSIsImlhdCI6MTc5MzMxODQwMCwiZXhwIjo0MTAyNDQ0ODAwfQ.sY9GVxj1-v3mOJvS4ev9s8MSmkxEorTL7e01C0TNkVQ. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Try it yourself
A signed token in the cookie

Line 39 deleted the cookie in the browser that signed out. But line 31 only checks the token's signature and its expiry, and this copy is signed by the shop and expires in 2100. Nothing on the server remembers the sign-out, so the copy still opens Asha's account: Signed in as Asha.

Both versions were built and run with Next.js 16.4.3. The recorder opened /account with a copy of Asha's cookie, signed out with that cookie, then opened /account with the same copy. Only lib/session.ts changes. Reset goes back to the first version.

With a signed token, the copy still opens Asha's account after she signed out. Nothing on the server remembers her sessions: the token proves itself, until it expires. (For the recording, this copy was made with the shop's secret to expire in 2100; the real ones last the 7 days createSession gives them.) With a database session, the cookie holds only a random id, the database keeps a row for each session, and deleteSession deletes the row. The same copy then finds nothing: You're not signed in. The table stores a SHA-256 of each id, not the id, so a leaked table can't be used as cookies.

After Asha signs out, someone sends a copy of her cookie. Left, with a signed token: the server checks the signature, which is still valid, and shows Signed in as Asha. Right, with a database session: the server looks for the session's row, which sign out deleted, and shows You're not signed in.A signed tokena copy of the cookiechecks the signatureSigned in as AshaA database sessiona copy of the cookielooks for its rowYou're not signed in.
Signing out deletes the cookie in one browser. A copy of a signed token still passes the signature check until it expires. A database session ends for every copy, because the row is gone.

Neither is always better. Each row asks one question of both:

A signed token or a database session
QuestionA signed token in the cookieA database session
Where does the session live?In the cookie: a signed token with the user's id and its expiry.In a sessions table. The cookie holds only a random id.
What does each request cost?Checking a signature. No database.One lookup in the sessions table.
Can the server end a session early?Not by itself: a copy works until it expires.Yes: delete the row, and the cookie stops working everywhere.
"Sign out of every device"Needs extra work, like a list of revoked tokens or a version number in the database.Delete all of that user's rows.
What can people read in it?Everything in the token: it's signed, not hidden. Keep it to an id.Only a random id that means nothing outside the shop.

People often say "cookies or JWTs", but they aren't two choices. A cookie is where the browser keeps a value; a JWT is a format for a signed value. This shop puts a JWT in a cookie. Some apps keep a token in localStorage instead and send it in a header, but every script on the page can read localStorage; an httpOnly cookie can't be read by any script. Apps that want stateless tokens and fast sign-out often make the token short-lived and keep a longer "refresh" session in the database. Libraries handle that for you.

Auth.js, Better Auth and Clerk

Everything above is code you'd write and keep safe yourself, and real sign-in needs more: email checks, password resets, "Sign in with Google", two-factor codes, rate limits. Next.js's docs point out how quickly that grows, and suggest an auth library. Three come up most often. Each row asks one question of them, from each one's docs and npm on October 30, 2026:

Auth.js, Better Auth and Clerk
QuestionAuth.jsBetter AuthClerk
What is it?An open-source library. Version 5 is called Auth.js; version 4 was NextAuth.js.An open-source library, with plugins for passkeys, two-factor sign-in, organizations and more.A hosted service, with its own sign-in pages and user management.
Where do users and sessions live?In your database through an adapter, or in a signed and encrypted cookie.In your database by default. Its docs also describe a cookie cache and stateless sessions.On Clerk's servers.
How does it meet Next.js 16?A Route Handler. Its docs export auth as proxy from proxy.ts, to keep the session alive.A Route Handler from toNextJsHandler, and nextCookies() so Server Actions can set its cookies.clerkMiddleware() in proxy.ts, and await auth() in Server Components, inside Suspense.
On npm, October 30, 20264.24.15, and 5.0.0-beta.32 as next-auth@beta, which its docs install1.7.147.9.22

Auth.js is the next version of NextAuth.js. Its docs install next-auth@beta, which is 5.0.0-beta.32 on npm; the stable next-auth is still version 4. Its docs also say the Better Auth team now handles security fixes for Auth.js, and recommend Better Auth for new projects:

Auth.js with Next.js 16, from its docs (not run here)TypeScript
// auth.ts
import NextAuth from "next-auth";

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [],
});

// app/api/auth/[...nextauth]/route.ts
export const { GET, POST } = handlers;

// proxy.ts
export { auth as proxy } from "@/auth";

Better Auth keeps users and sessions in your own database by default, and answers its own requests from one Route Handler:

Better Auth with Next.js 16, from its docs (not run here)TypeScript
// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);

// In a Server Component or a Server Action
const session = await auth.api.getSession({ headers: await headers() });

Clerk is a hosted service: Clerk keeps the users and runs the sign-in pages, and your app checks its sessions. Its docs name the file by the Next.js version: proxy.ts on 16, middleware.ts on 15 and older. With Cache Components on, they say to keep auth() inside Suspense:

Clerk with Next.js 16, from its docs (not run here)TypeScript
// proxy.ts (it also exports a matcher, in Clerk's docs)
import { clerkMiddleware } from "@clerk/nextjs/server";

export default clerkMiddleware();

// In a Server Component, inside <Suspense>
import { auth } from "@clerk/nextjs/server";

const { userId } = await auth();

These snippets need accounts, keys or a database, so they aren't recorded here. On npm, all three list Next.js 16 among the versions they work with. Better Auth's and Clerk's docs also agree on proxy.ts: a check there is a quick first look, and the real check belongs next to the data, which is the next lesson.

Safe or unsafe?

Each card is a choice a sign-in system makes. Sort each one:

Safe or unsafe?
  • Saving the password as typed, so you can email it to people who forget it
  • Saving sha256(password)
  • Saving scrypt's hash with a random salt for each user
  • Answering No account with that email on the sign-in form
  • Keeping the session token in localStorage
  • A session cookie with httpOnly, secure and sameSite lax
  • Putting the user's email and role in the JWT, because the signature protects it
  • Deleting the session's row when the user signs out
Try it yourself
0 of 8 correct

Sort each choice: does it keep accounts safe, or does it open a hole?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "A JWT is encrypted." No. A signed JWT is only encoded: anyone with it can read the payload. The signature stops changes, not reading.
  • "Hashing with SHA-256 is enough." No. It's built to be fast, which helps guessing. Use a slow hash with a salt: scrypt, bcrypt or Argon2.
  • "Signing out ends the session everywhere." Only with sessions in a database. With a signed token, a copy works until it expires, as the recording shows.
  • "httpOnly means HTTP, not HTTPS." No. It means scripts can't read the cookie, though an injected script can still send requests with it. secure is the HTTPS one.
  • "A Client Component that shows only the name only receives the name." No. It receives every prop you pass, whole: the recorded payload held the password hash.
  • "Cookies or JWTs: pick one." A cookie is where the value travels; a JWT is a format. The shop puts a JWT in a cookie.
  • "Say which part was wrong, to be helpful." On a sign-in form, that tells anyone which emails have accounts. One answer for both.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upWhat's in the users table?

Ben signs up with the password lapsang-42-tea. What does the users table hold in his passwordHash?

Starter codeTypeScript
import "server-only";
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";

type Cost = { N: number; r: number; p: number };

// OWASP's baseline for scrypt: N = 2^17 (128 MiB of memory for each hash), r = 8, p = 1.
const POLICY: Cost = { N: 2 ** 17, r: 8, p: 1 };

function scryptAsync(password: string, salt: Buffer, cost: Cost) {
  // scrypt needs 128 × N × r bytes; Node.js refuses more than 32 MiB unless maxmem says so.
  const options = { ...cost, maxmem: 256 * 1024 * 1024 };
  return new Promise<Buffer>((resolve, reject) => {
    scrypt(password, salt, 64, options, (error, key) => (error ? reject(error) : resolve(key)));
  });
}

// Stored as "scrypt$N$r$p$salt$hash": the cost goes with each hash, so a later, higher cost
// can still check the hashes made today.
export async function hashPassword(password: string) {
  const salt = randomBytes(16);
  const hash = await scryptAsync(password, salt, POLICY);
  return ["scrypt", POLICY.N, POLICY.r, POLICY.p, salt.toString("hex"), hash.toString("hex")].join("$");
}

export async function verifyPassword(password: string, stored: string) {
  const [name, n, r, p, salt = "", hash = ""] = stored.split("$");
  const cost = { N: Number(n), r: Number(r), p: Number(p) };
  // Only costs this shop would use: a power of two from 2^14 to 2^17, r = 8, p from 1 to 4.
  const known = Number.isInteger(Math.log2(cost.N)) && cost.N >= 2 ** 14 && cost.N <= 2 ** 17 && cost.r === 8 && cost.p >= 1 && cost.p <= 4;
  if (name !== "scrypt" || !known || !/^[0-9a-f]{32}$/.test(salt) || !/^[0-9a-f]{128}$/.test(hash)) return false;
  const expected = Buffer.from(hash, "hex");
  const actual = await scryptAsync(password, Buffer.from(salt, "hex"), cost);
  return actual.length === expected.length && timingSafeEqual(actual, expected);
}

// Checked when no user has the email, at the same cost, so that answer takes as long as a wrong password.
export const NO_USER_HASH = ["scrypt", POLICY.N, POLICY.r, POLICY.p, "0".repeat(32), "0".repeat(128)].join("$");

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upAn email with no account

    Someone signs in as nobody@example.com with Asha's password. What does the form say?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the missing option

      A teammate's lib/session.ts lets any script on the page read the session cookie. Which option is missing?

      Starter codeTypeScript
      import "server-only";
      import { SignJWT, jwtVerify } from "jose";
      import { cookies } from "next/headers";
      
      // 32 random bytes or more, in base64: make them with `openssl rand -base64 32`.
      const secret = process.env.SESSION_SECRET ?? "";
      const key = Buffer.from(secret, "base64");
      if (!/^[A-Za-z0-9+/]+={0,2}$/.test(secret) || key.length < 32) {
        throw new Error("Set SESSION_SECRET to a random secret: openssl rand -base64 32");
      }
      
      export async function createSession(userId: string) {
        const token = await new SignJWT({ userId })
          .setProtectedHeader({ alg: "HS256" })
          .setIssuedAt()
          .setExpirationTime("7d")
          .sign(key);
        (await cookies()).set("session", token, {
          secure: true,
          sameSite: "lax",
          path: "/",
          maxAge: 60 * 60 * 24 * 7,
        });
      }
      
      export async function readSession() {
        const token = (await cookies()).get("session")?.value;
        if (!token) return null;
        try {
          const { payload } = await jwtVerify(token, key, { algorithms: ["HS256"] });
          return typeof payload.userId === "string" ? { userId: payload.userId } : null;
        } catch {
          return null;
        }
      }
      
      export async function deleteSession() {
        (await cookies()).delete("session");
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeAn edited token

        Someone decodes Asha's token, changes u1 to u2, and sends it back as their cookie. What does /account show?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeStop the hash from leaking

          This account page sends Asha's password hash to the browser, in the RSC payload. What should line 15 pass to AccountMenu instead?

          Starter codeTSX
          import { Suspense } from "react";
          import Link from "next/link";
          import { readSession } from "@/lib/session";
          import { findUserById } from "@/lib/users";
          import { getMyOrders } from "@/lib/orders";
          import AccountMenu from "./account-menu";
          
          async function Account() {
            const session = await readSession();
            const user = session && (await findUserById(session.userId));
            if (!user) return <p>You're not signed in. <Link href="/sign-in">Sign in</Link></p>;
            const orders = await getMyOrders();
            return (
              <>
                <AccountMenu user={user} />
                <ul>
                  {orders.map((order) => (
                    <li key={order.id}>{order.boxes} boxes of {order.tea}</li>
                  ))}
                </ul>
              </>
            );
          }
          
          export default function AccountPage() {
            return (
              <main>
                <h1>Your account</h1>
                <Suspense fallback={<p>Loading your account…</p>}>
                  <Account />
                </Suspense>
              </main>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeSign out of every device

            The shop wants a Sign out of every device button that works at once, even for a stolen cookie. Which kind of session makes that easy?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what the server stores, what the browser holds, and who can read or change it.

              The authentication quiz · 7 questionsScore: first tries count
              1. Question 1 of 7This is lib/password.ts. Asha and Ben both choose the password green-tea-123. What does the users table hold?

                Read the code, then predictTypeScript
                import "server-only";
                import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
                
                type Cost = { N: number; r: number; p: number };
                
                // OWASP's baseline for scrypt: N = 2^17 (128 MiB of memory for each hash), r = 8, p = 1.
                const POLICY: Cost = { N: 2 ** 17, r: 8, p: 1 };
                
                function scryptAsync(password: string, salt: Buffer, cost: Cost) {
                  // scrypt needs 128 × N × r bytes; Node.js refuses more than 32 MiB unless maxmem says so.
                  const options = { ...cost, maxmem: 256 * 1024 * 1024 };
                  return new Promise<Buffer>((resolve, reject) => {
                    scrypt(password, salt, 64, options, (error, key) => (error ? reject(error) : resolve(key)));
                  });
                }
                
                // Stored as "scrypt$N$r$p$salt$hash": the cost goes with each hash, so a later, higher cost
                // can still check the hashes made today.
                export async function hashPassword(password: string) {
                  const salt = randomBytes(16);
                  const hash = await scryptAsync(password, salt, POLICY);
                  return ["scrypt", POLICY.N, POLICY.r, POLICY.p, salt.toString("hex"), hash.toString("hex")].join("$");
                }
                
                export async function verifyPassword(password: string, stored: string) {
                  const [name, n, r, p, salt = "", hash = ""] = stored.split("$");
                  const cost = { N: Number(n), r: Number(r), p: Number(p) };
                  // Only costs this shop would use: a power of two from 2^14 to 2^17, r = 8, p from 1 to 4.
                  const known = Number.isInteger(Math.log2(cost.N)) && cost.N >= 2 ** 14 && cost.N <= 2 ** 17 && cost.r === 8 && cost.p >= 1 && cost.p <= 4;
                  if (name !== "scrypt" || !known || !/^[0-9a-f]{32}$/.test(salt) || !/^[0-9a-f]{128}$/.test(hash)) return false;
                  const expected = Buffer.from(hash, "hex");
                  const actual = await scryptAsync(password, Buffer.from(salt, "hex"), cost);
                  return actual.length === expected.length && timingSafeEqual(actual, expected);
                }
                
                // Checked when no user has the email, at the same cost, so that answer takes as long as a wrong password.
                export const NO_USER_HASH = ["scrypt", POLICY.N, POLICY.r, POLICY.p, "0".repeat(32), "0".repeat(128)].join("$");
                

                Choose an answer to see the explanation.

              2. Question 2 of 7Someone types an email that has no account. What does the recorded sign-in form answer?

                Choose an answer to see the explanation.

              3. Question 3 of 7Why is the session cookie httpOnly?

                Choose an answer to see the explanation.

              4. Question 4 of 7Someone decodes Asha's session token, changes u1 to u2, and sends it back. What happens?

                Choose an answer to see the explanation.

              5. Question 5 of 7The account page hands the whole users-table row to AccountMenu, a Client Component, which shows only the name. What reaches the browser?

                Choose an answer to see the explanation.

              6. Question 6 of 7Someone kept a copy of Asha's signed-token cookie. Asha signs out. What does the copy open?

                Choose an answer to see the explanation.

              7. Question 7 of 7Where should a Next.js 16 app read the signed-in user to show their name?

                Choose an answer to see the explanation.

              Key takeaways

              • Store a slow hash like scrypt at a stated cost, with a salt (scrypt$N$r$p$salt$hash), never the password, and compare with timingSafeEqual.
              • Sign in with a Server Action: check the fields with Zod, give one answer for every failure, set the session cookie, then redirect.
              • Set the session cookie httpOnly, secure, sameSite lax, with a path and an expiry. A signed JWT can be read by anyone, but changed by nobody without the secret.
              • Read the session in a Server Component inside Suspense, and pass Client Components only what they show.
              • Signing out deletes the cookie. Only a database session can also stop a copy of it before it expires.
              • Auth.js, Better Auth and Clerk do all of this and more, and all three work with proxy.ts in Next.js 16.

              Remember the one-liner.
              Check the password once against a salted hash, then let a signed, httpOnly cookie say who's asking until it expires or you take it back.

              Read it in Next.js's own code

              cookies() is in packages/next/src/server/request/cookies.ts. While a Server Component renders, its set and delete throw the error in lines 12 to 22 of request-cookies.ts: Cookies can only be modified in a Server Action or Route Handler. The docs give the reason: HTTP doesn't allow setting cookies after streaming starts. That's why the session is set in the Server Action.

              Coming next: Authorization and the data access layer. Knowing who Asha is, it checks what she may read and change, next to the data, in every page, Server Action and Route Handler.

              CompleteFrontend Clear concepts. Working examples.