4. Locking it down
The auth endpoints have been running since chapter 1. You did not write them, and you are not going to. What this chapter does is connect them to your todos.
What you already have
Section titled “What you already have”POST /auth/signup name, email, passwordPOST /auth/login returns an access token, sets a refresh cookiePOST /auth/refresh rotates bothPOST /auth/logout genuinely revokes the sessionGET /auth/me the signed-in userPOST /auth/change-password signs every other session outPOST /auth/forgot-password emails a single-use linkPOST /auth/reset-password redeems itBehind them: argon2id hashing, a short-lived access JWT with a revocable refresh row, single-use hashed reset tokens, and rate limiting on login, signup and reset keyed on both IP and email. Login also refuses to reveal which addresses have accounts — a wrong password and an unknown email return identical bodies, identical statuses, and comparable timing.
That list is the argument for not writing auth yourself. Every item on it is something that gets missed at the end of a long Friday.
Guarding a route
Section titled “Guarding a route”Add one line to each of your four route files:
export default route( { summary: 'Create a todo', tags: ['todos'], auth: true, body: z.object({ /* … */ }), }, async (ctx) => { const [todo] = await ctx.db .insert(todos) .values({ id: crypto.randomUUID(), userId: ctx.user.id, // ← no null check // … }) .returning()
ctx.status = 201 return todo },)ctx.user.id, with no if (!ctx.user) in front of it.
The guard runs before the handler, so a route declaring auth: true is unreachable
anonymously — and TypeScript knows, narrowing ctx.user to non-null inside it. Delete the
auth: true line and that ctx.user.id stops compiling. The type system is enforcing the guard,
not just describing it.
Scoping the queries
Section titled “Scoping the queries”A guard says someone is signed in. It does not say the todo is theirs. That is your job, and it is the part worth being careful about.
Listing
Section titled “Listing”import { and, desc, eq } from 'drizzle-orm'import { z } from 'zod'import { route } from '../../route'import { todos } from '../../schema'
export default route( { auth: true, summary: 'List your todos', tags: ['todos'], query: z.object({ done: z.stringbool().optional(), limit: z.coerce.number().int().min(1).max(100).default(20), }), }, (ctx) => { const mine = eq(todos.userId, ctx.user.id)
return ctx.db .select() .from(todos) .where(ctx.query.done === undefined ? mine : and(mine, eq(todos.done, ctx.query.done))) .orderBy(desc(todos.createdAt)) .limit(ctx.query.limit) },)mine is built once and every branch includes it. Written as a chain of optional filters, the
ownership check is one if away from being skipped — this way it cannot be.
z.stringbool() turns ?done=true into a real boolean; "false", "0" and "no" all work,
which Boolean("false") === true famously does not.
Updating and deleting
Section titled “Updating and deleting”const [updated] = await ctx.db .update(todos) .set(ctx.body) .where(and(eq(todos.id, ctx.params.id), eq(todos.userId, ctx.user.id))) .returning()
if (!updated) throw new NotFound(`No todo with id ${ctx.params.id}.`)return updatedThe ownership check is in the where clause, not in an if after the read.
That matters for two reasons. It is one query rather than a read-then-write, so nothing can
change between them. And it fails as a 404, not a 403 — which is the right answer, because
telling a stranger “that exists but is not yours” confirms the id exists. DELETE gets the same
treatment.
See it work
Section titled “See it work”# Ada signs up and creates somethingADA=$(curl -s -X POST localhost:3000/auth/signup -H 'content-type: application/json' \
ID=$(curl -s -X POST localhost:3000/todos -H "authorization: Bearer $ADA" \ -H 'content-type: application/json' -d '{"title":"Ada private"}' | jq -r .id)
# Mallory signs up and goes lookingMAL=$(curl -s -X POST localhost:3000/auth/signup -H 'content-type: application/json' \ -d '{"name":"Mallory","email":"[email protected]","password":"correct-horse"}' | jq -r .accessToken)
curl -s localhost:3000/todos -H "authorization: Bearer $MAL"# []
curl -s -o /dev/null -w '%{http_code}\n' -X PATCH localhost:3000/todos/$ID \ -H "authorization: Bearer $MAL" -H 'content-type: application/json' -d '{"title":"pwned"}'# 404
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:3000/todos/$ID \ -H "authorization: Bearer $MAL"# 404Ada’s todo is invisible, unmodifiable and undeletable. And without a token at all:
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/todos# 401When “signed in” is not enough
Section titled “When “signed in” is not enough”auth: true means someone. For anything more, write a named policy:
const policies = { admin: (user) => user.raw.role === 'admin',}
app.use(auth(provider, { policies }))export default route({ auth: 'admin' }, handler)A policy is a plain function — testable, greppable, and it appears in your OpenAPI document with
its name and a documented 403. There is deliberately no built-in role: 'admin' guard: roles
are not normalised across providers, so a portable one would fail silently on any provider that
models permissions differently.