Appearance
Protecting routes
AuthN = who are you? AuthZ = are you allowed? Middleware answers the first; a bit of ownership logic answers the second.
requireAuth middleware
ts
// src/middleware/auth.ts
import type { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { config } from '../config.js'
import { findUserById, publicUser } from '../db.js'
import { HttpError } from '../errors.js'
export type AuthedRequest = Request & {
user?: { id: number; email: string; emailVerified: boolean }
}
export function requireAuth(req: AuthedRequest, _res: Response, next: NextFunction) {
try {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
throw new HttpError(401, 'unauthorized')
}
const token = header.slice('Bearer '.length)
const payload = jwt.verify(token, config.jwtSecret) as { sub: number }
const user = findUserById(Number(payload.sub))
if (!user) throw new HttpError(401, 'unauthorized')
req.user = publicUser(user)
next()
} catch {
next(new HttpError(401, 'unauthorized'))
}
}Comments for the code above:
- Invalid / expired JWT → 401 (don't leak verify error details)
- We re-load the user from DB so deleted accounts stop working even if the token hasn't expired
req.useris the public shape - attach only what handlers need
Protect a route
ts
app.get('/api/me', requireAuth, (req: AuthedRequest, res) => {
res.json({ user: req.user })
})
notesRouter.use(requireAuth) // all note routes need a token
notesRouter.get('/', (req: AuthedRequest, res) => {
res.json({ notes: listNotesForUser(req.user!.id) })
})AuthN vs AuthZ
ts
// AuthN: requireAuth already ran
notesRouter.delete('/:id', requireAuth, (req: AuthedRequest, res, next) => {
try {
const note = findNote(Number(req.params.id))
if (!note) throw new HttpError(404, 'not found')
if (note.user_id !== req.user!.id) {
throw new HttpError(403, 'forbidden') // AuthZ
}
deleteNote(note.id)
res.status(204).end()
} catch (err) {
next(err)
}
})| Status | Meaning |
|---|---|
| 401 | No/invalid login |
| 403 | Logged in, not allowed |
FE folks mix these up constantly (me included). Matching the status helps the SPA choose "redirect to login" vs "show toast".
Optional: verified-email gate
ts
export function requireVerified(req: AuthedRequest, _res: Response, next: NextFunction) {
if (!req.user?.emailVerified) {
return next(new HttpError(403, 'email not verified'))
}
next()
}
app.post('/api/notes', requireAuth, requireVerified, createNoteHandler)Useful once verification emails exist.
Roles (sketch only)
ts
if (req.user.role !== 'admin') throw new HttpError(403, 'forbidden')Full RBAC is a product. For this tutorial, ownership checks cover 90% of "authorization" intuition.
SPA pattern
ts
async function api(path: string, options: RequestInit = {}) {
const token = localStorage.getItem('token')
const res = await fetch(`http://localhost:3000${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
})
if (res.status === 401) {
// clear token, route to /login
}
return res
}As you can see, the server decides; the SPA just reacts to 401/403.
Next: sending that "please confirm your email" message for real.
Best,
-- ł.