Skip to content

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.user is 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)
  }
})
StatusMeaning
401No/invalid login
403Logged 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,

-- ł.

Notes-style Express tutorial for front-end folks