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.

19 min 9 sections

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:

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

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:

sql
-- 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);
ts
// 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:

ts
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

ts
// 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 verifyPassword against 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:

ts
// 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()
  })
ts
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:

ts
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. Lax cookies are still sent on top-level cross-site GET navigation.
  • Check the origin of state-changing requests. Hono's csrf middleware rejects unsafe requests whose Origin (or Sec-Fetch-Site) header doesn't match your allowed origins:
ts
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 holdsA random IDA signed document containing the user's ID and claims
Each requestOne indexed database or Redis lookupVerify a signature; no lookup
Logout and revocationDelete the row: instantHard: the token is valid until it expires, unless you keep a denylist
Changing a user's roleTakes effect immediatelyOnly after the token is reissued
FitsBrowser apps talking to their own backendService-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:

  1. Apply the users and sessions migration, and add an orders.customer_id column referencing users (a new migration).
  2. Implement register, login, logout and GET /me as shown.
  3. Make POST /orders use the signed-in user as the customer, and GET /orders/:id return 404 for other customers' orders (admins see all).
  4. Using app.request, check: register then login sets an HttpOnly cookie; /me works 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
sql
-- 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);
ts
// 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()
text
204 session=...; Path=/; Expires=...; HttpOnly; Secure; SameSite=Lax
200
401
401 401 true
404
401

Logout 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=Lax cookies.
  • 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. 1.Why is SHA-256 a poor choice for hashing passwords but fine for hashing session tokens?

  2. 2.Why does login run verifyPassword against a dummy hash when the email doesn't exist?

  3. 3.A signed-in user requests /orders/42, which belongs to someone else. What should the API do?

  4. 4.What does the HttpOnly cookie flag protect against?

Finished reading?

Mark it complete to fill in your roadmap.