Step 09 of 12
Authentication & sessions
Add accounts to a Node API: hash passwords with scrypt, issue session tokens in secure cookies, load the user in middleware, authorise by role and ownership, and stop CSRF.
Authentication answers "who is making this request?"; authorisation answers "are they allowed to do this?". Both are easy to get subtly wrong, and mistakes here leak data or hand over accounts. This lesson builds the standard, boring and secure approach for a web API: passwords hashed with a slow key-derivation function, random session tokens stored hashed in PostgreSQL and sent in HttpOnly cookies, middleware that loads the current user, explicit authorisation checks, and CSRF protection. It ends with when tokens such as JWTs make sense instead.
Storing passwords
Never store passwords, and never store a plain hash such as SHA-256 of them either. Fast hashes can be computed billions of times per second on a GPU, so a leaked table of SHA-256 password hashes is cracked within hours. Use a password hashing function that is deliberately slow and memory-hungry, with a random salt per password so identical passwords get different hashes:
- Argon2id: the current recommendation (needs a native package such as
@node-rs/argon2). - scrypt: built into Node's
node:crypto, no dependency, and a solid choice. - bcrypt: older but still acceptable.
With scrypt:
// src/auth/passwords.ts
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
const scrypt = promisify(scryptCb) as (
password: string, salt: Buffer, keylen: number, options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>
const PARAMS = { N: 2 ** 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 } // ~32 MiB and ~50 ms per hash
const KEY_LENGTH = 32
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16)
const key = await scrypt(password, salt, KEY_LENGTH, PARAMS)
return `scrypt$${PARAMS.N}$${PARAMS.r}$${PARAMS.p}$${salt.toString('base64')}$${key.toString('base64')}`
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const [algo, N, r, p, saltB64, keyB64] = stored.split('$')
if (algo !== 'scrypt' || !saltB64 || !keyB64) return false
const expected = Buffer.from(keyB64, 'base64')
const actual = await scrypt(password, Buffer.from(saltB64, 'base64'), expected.length, {
N: Number(N), r: Number(r), p: Number(p), maxmem: PARAMS.maxmem,
})
return timingSafeEqual(actual, expected)
}The stored string records the parameters, so you can raise them later and still verify old hashes (rehash on the next successful login). timingSafeEqual compares in constant time, so the comparison doesn't reveal how many bytes matched. Use the async scrypt: the synchronous version would block the event loop for every login.
Also enforce a minimum length (at least 12 characters), don't impose composition rules ("one symbol"), and allow long passphrases (cap at a few hundred characters so nobody can send a 10 MB password to burn your CPU).
Sessions: a random token in a cookie
After a successful login, the server creates a session: a random, unguessable token that the browser sends back on every request. Store only a hash of the token in the database, so a leaked sessions table can't be used to log in:
-- migrations/002_users_and_sessions.sql
CREATE TABLE users (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
role text NOT NULL DEFAULT 'customer' CHECK (role IN ('customer', 'admin')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE sessions (
token_hash bytea PRIMARY KEY,
user_id integer NOT NULL REFERENCES users (id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX sessions_user_id_idx ON sessions (user_id);// src/auth/sessions.ts
import { createHash, randomBytes } from 'node:crypto'
import { pool } from '../db.ts'
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
const hashToken = (token: string) => createHash('sha256').update(token).digest()
export type SessionUser = { id: number; email: string; role: 'customer' | 'admin' }
export async function createSession(userId: number): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(32).toString('base64url') // 256 bits: unguessable
const expiresAt = new Date(Date.now() + SESSION_TTL_MS)
await pool.query('INSERT INTO sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3)', [
hashToken(token), userId, expiresAt,
])
return { token, expiresAt }
}
export async function userForSession(token: string): Promise<SessionUser | undefined> {
const { rows } = await pool.query<SessionUser>(
`SELECT u.id, u.email, u.role FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > now()`,
[hashToken(token)],
)
return rows[0]
}
export async function deleteSession(token: string) {
await pool.query('DELETE FROM sessions WHERE token_hash = $1', [hashToken(token)])
}SHA-256 is fine here, unlike for passwords, because the token is 256 random bits: there's nothing to guess.
The token travels in a cookie with three protective flags:
import { setCookie } from 'hono/cookie'
setCookie(c, 'session', token, {
httpOnly: true, // page JavaScript can't read it, so an XSS bug can't steal it
secure: true, // only sent over HTTPS
sameSite: 'Lax', // not sent on cross-site POSTs: the main CSRF defence
path: '/',
expires: expiresAt,
})Browsers treat localhost as a secure context, so secure: true works in local development too.
Register, log in, log out
// src/auth/routes.ts
import { Hono } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { z } from 'zod'
import { pool } from '../db.ts'
import { validate } from '../http/errors.ts'
import { hashPassword, verifyPassword } from './passwords.ts'
import { createSession, deleteSession } from './sessions.ts'
const Credentials = z.object({
email: z.email().transform((e) => e.toLowerCase()),
password: z.string().min(12).max(256),
})
// A real hash of a random password, used when the email doesn't exist (see below).
const DUMMY_HASH = await hashPassword(crypto.randomUUID())
export const authRoutes = new Hono()
.post('/register', validate('json', Credentials), async (c) => {
const { email, password } = c.req.valid('json')
const passwordHash = await hashPassword(password)
const { rows } = await pool.query<{ id: number }>(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) ON CONFLICT (email) DO NOTHING RETURNING id',
[email, passwordHash],
)
if (!rows[0]) return c.json({ error: { code: 'email_taken', message: 'That email is already registered.' } }, 409)
return c.json({ id: rows[0].id, email }, 201)
})
.post('/login', validate('json', Credentials), async (c) => {
const { email, password } = c.req.valid('json')
const { rows } = await pool.query<{ id: number; password_hash: string }>(
'SELECT id, password_hash FROM users WHERE email = $1', [email],
)
const user = rows[0]
const ok = await verifyPassword(password, user?.password_hash ?? DUMMY_HASH)
if (!user || !ok) return c.json({ error: { code: 'invalid_credentials', message: 'Wrong email or password.' } }, 401)
const { token, expiresAt } = await createSession(user.id)
setCookie(c, 'session', token, { httpOnly: true, secure: true, sameSite: 'Lax', path: '/', expires: expiresAt })
return c.body(null, 204)
})
.post('/logout', async (c) => {
const token = getCookie(c, 'session')
if (token) await deleteSession(token)
deleteCookie(c, 'session', { path: '/', secure: true })
return c.body(null, 204)
})Two details protect against account enumeration (an attacker discovering which emails have accounts):
- The same message, "Wrong email or password", whether the email exists or not.
- The same timing: when the email doesn't exist, the code still runs
verifyPasswordagainst a dummy hash. Otherwise "no such user" would answer in 1 ms and "wrong password" in 50 ms, and the difference would reveal the account.
Registration still reveals whether an email is taken; products that care send a "check your email" message in both cases instead. Rate-limit login attempts per account and per IP, or this endpoint invites password guessing at scale.
Loading the user in middleware
A middleware reads the cookie, looks up the session, and makes the user available to handlers:
// src/auth/middleware.ts
import { getCookie } from 'hono/cookie'
import { createMiddleware } from 'hono/factory'
import { HTTPException } from 'hono/http-exception'
import { userForSession, type SessionUser } from './sessions.ts'
export type AuthEnv = { Variables: { user: SessionUser } }
export const requireUser = createMiddleware<AuthEnv>(async (c, next) => {
const token = getCookie(c, 'session')
const user = token ? await userForSession(token) : undefined
if (!user) throw new HTTPException(401, { message: 'Please sign in.' })
c.set('user', user)
await next()
})
export const requireRole = (role: SessionUser['role']) =>
createMiddleware<AuthEnv>(async (c, next) => {
if (c.get('user').role !== role) throw new HTTPException(403, { message: 'Not allowed.' })
await next()
})const app = new Hono<AuthEnv>()
app.get('/me', requireUser, (c) => c.json(c.get('user'))) // c.get('user') is typed
app.get('/admin/stats', requireUser, requireRole('admin'), (c) => c.json({ ok: true }))Authorisation: roles and ownership
Authentication isn't permission. The most common serious API vulnerability is broken object-level authorisation: a signed-in user changes /orders/41 to /orders/42 and sees someone else's order, because the handler checked that a user is signed in, but not that this user owns this order.
Put the ownership condition in the query itself, so it can't be forgotten in one code path:
app.get('/orders/:id', requireUser, validate('param', IdParam), async (c) => {
const user = c.get('user')
const { rows } = await pool.query(
`SELECT * FROM orders WHERE id = $1 AND ($2 = 'admin' OR customer_id = $3)`,
[c.req.valid('param').id, user.role, user.id],
)
if (!rows[0]) throw new NotFoundError('order', c.req.valid('param').id) // 404, not 403
return c.json(rows[0])
})Returning 404 rather than 403 for other people's resources avoids confirming that the ID exists. Identity always comes from the session, never from the request body: an endpoint that accepts { "userId": 7 } and trusts it lets anyone act as user 7.
CSRF
Cookies are sent automatically, which creates cross-site request forgery: a malicious site makes the victim's browser send a request to your API, cookies attached. SameSite=Lax stops cookies on cross-site POST, PUT and DELETE, which removes most of the risk. Add two more layers:
- Never change state on GET.
Laxcookies are still sent on top-level cross-site GET navigation. - Check the origin of state-changing requests. Hono's
csrfmiddleware rejects unsafe requests whoseOrigin(orSec-Fetch-Site) header doesn't match your allowed origins:
import { csrf } from 'hono/csrf'
app.use(csrf({ origin: ['https://app.example.com'] }))Sessions or tokens (JWT)?
| Server-side sessions (this lesson) | Stateless tokens (JWT) | |
|---|---|---|
| What the client holds | A random ID | A signed document containing the user's ID and claims |
| Each request | One indexed database or Redis lookup | Verify a signature; no lookup |
| Logout and revocation | Delete the row: instant | Hard: the token is valid until it expires, unless you keep a denylist |
| Changing a user's role | Takes effect immediately | Only after the token is reissued |
| Fits | Browser apps talking to their own backend | Service-to-service calls, third-party APIs, short-lived access tokens with refresh tokens |
For a web app and its own API, sessions are simpler and safer. JWTs solve a different problem, letting one service trust a claim issued by another without a shared database, and are usually paired with short expiry and refresh tokens. Storing long-lived JWTs in localStorage makes them readable by any XSS bug, which is exactly what HttpOnly cookies avoid.
Practice
Add accounts to the orders API:
- Apply the users and sessions migration, and add an
orders.customer_idcolumn referencingusers(a new migration). - Implement register, login, logout and
GET /meas shown. - Make
POST /ordersuse the signed-in user as the customer, andGET /orders/:idreturn 404 for other customers' orders (admins see all). - Using
app.request, check: register then login sets anHttpOnlycookie;/meworks with the cookie and returns 401 without it; a wrong password and an unknown email both return the same 401 body; user B gets 404 for user A's order; after logout, the old cookie no longer works.
Show solution
-- migrations/003_order_customer.sql
ALTER TABLE orders ADD COLUMN customer_id integer REFERENCES users (id);
CREATE INDEX orders_customer_id_idx ON orders (customer_id);// check-auth.ts: the routes from the lesson, mounted on one app
import { Hono } from 'hono'
import { HTTPException } from 'hono/http-exception'
import { pool } from './src/db.ts'
import { migrate } from './src/migrate.ts'
import { authRoutes } from './src/auth/routes.ts'
import { requireUser, type AuthEnv } from './src/auth/middleware.ts'
await migrate(pool)
const app = new Hono<AuthEnv>()
app.route('/auth', authRoutes)
app.get('/me', requireUser, (c) => c.json(c.get('user')))
app.post('/orders', requireUser, async (c) => {
const { rows } = await pool.query<{ id: number }>(
'INSERT INTO orders (customer_email, customer_id, total_cents) VALUES ($1, $2, 0) RETURNING id',
[c.get('user').email, c.get('user').id],
)
return c.json(rows[0], 201)
})
app.get('/orders/:id', requireUser, async (c) => {
const user = c.get('user')
const { rows } = await pool.query(
`SELECT id, customer_id FROM orders WHERE id = $1 AND ($2 = 'admin' OR customer_id = $3)`,
[Number(c.req.param('id')), user.role, user.id],
)
return rows[0] ? c.json(rows[0]) : c.json({ error: { code: 'not_found' } }, 404)
})
app.onError((err, c) => (err instanceof HTTPException ? c.json({ error: err.message }, err.status) : c.json({ error: 'internal' }, 500)))
const json = (body: unknown, cookie?: string) => ({
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(cookie ? { Cookie: cookie } : {}) },
body: JSON.stringify(body),
})
const sessionCookie = (res: Response) => res.headers.get('set-cookie')!.split(';')[0]!
async function signUpAndIn(email: string) {
await app.request('/auth/register', json({ email, password: 'correct horse battery' }))
const res = await app.request('/auth/login', json({ email, password: 'correct horse battery' }))
return { res, cookie: sessionCookie(res) }
}
const a = await signUpAndIn('[email protected]')
console.log(a.res.status, a.res.headers.get('set-cookie')) // 204 session=...; HttpOnly; Secure; SameSite=Lax
console.log((await app.request('/me', { headers: { Cookie: a.cookie } })).status) // 200
console.log((await app.request('/me')).status) // 401
const wrong = await app.request('/auth/login', json({ email: '[email protected]', password: 'wrong password!!' }))
const unknown = await app.request('/auth/login', json({ email: '[email protected]', password: 'wrong password!!' }))
console.log(wrong.status, unknown.status, (await wrong.text()) === (await unknown.text())) // 401 401 true
const order = await (await app.request('/orders', json({}, a.cookie))).json()
const b = await signUpAndIn('[email protected]')
console.log((await app.request(`/orders/${order.id}`, { headers: { Cookie: b.cookie } })).status) // 404
await app.request('/auth/logout', { method: 'POST', headers: { Cookie: a.cookie } })
console.log((await app.request('/me', { headers: { Cookie: a.cookie } })).status) // 401
await pool.end()204 session=...; Path=/; Expires=...; HttpOnly; Secure; SameSite=Lax
200
401
401 401 true
404
401Logout deletes the session row, so the old cookie stops working at once even though the browser might still send it: that's the revocation property server-side sessions give you. Because the dummy-hash comparison runs for unknown emails, both failed logins take about the same time as well as returning identical bodies.
Key takeaways
- Hash passwords with a slow, salted function (Argon2id, or scrypt from
node:crypto), asynchronously, and compare in constant time. - Sessions are random 256-bit tokens, stored hashed with an expiry and sent in
HttpOnly,Secure,SameSite=Laxcookies. - Give identical responses and similar timing for unknown emails and wrong passwords, and rate-limit login.
- Load the user in middleware, take identity only from the session, and enforce ownership in the query itself.
- Defend against CSRF with
SameSite, no state changes on GET, and origin checks; prefer sessions over JWTs for a web app and its own API.
Check yourself
Quiz
0/4 correct · 4 left
1.Why is SHA-256 a poor choice for hashing passwords but fine for hashing session tokens?
2.Why does login run verifyPassword against a dummy hash when the email doesn't exist?
3.A signed-in user requests /orders/42, which belongs to someone else. What should the API do?
4.What does the HttpOnly cookie flag protect against?
Finished reading?
Mark it complete to fill in your roadmap.