Contracts and security

Place the authentication boundary

Separate identifying a caller from deciding whether that caller may perform one specific operation.

Two questions hide behind the word “auth”, and mixing them up causes most access bugs. Authentication answers “who is calling?”. Authorization answers “may this caller do this to this book?”. They live in different places in the code, and this lesson is about where.

We won’t build a login system here. Passwords, sessions and tokens get their own course, Web Authentication. What we build is the boundary everything else plugs into.

Authentication is middleware

Identifying the caller happens once, before any handler, in middleware. The middleware reads the credential, verifies it, and either rejects the request or attaches a small trusted principal to the context: an internal user ID and maybe some roles.

For development, a header can stand in for a real token:

app.use('/books/*', async (c, next) => {
  const userId = c.req.header('X-Dev-User')
  if (!userId) return problem(c, 401, 'Authentication required')
  c.set('user', { id: userId })
  await next()
})

For TypeScript to know about that variable, create the app as new Hono<{ Variables: { user: { id: string } } }>().

Handlers then read c.get('user') and trust it. They never parse credentials themselves, and they never trust an ownerId field inside the JSON body. A body is what the client claims. The principal is what the server verified. Keep the raw credential out of both the principal and the logs.

Authorization is part of the query

Now the second question. Say each book has an owner_id, and only the owner may update it. The tempting version is: load the book, check book.owner_id === user.id, then update. That leaves a gap between the check and the write where another request can change things.

The safer version puts the ownership condition inside the write:

const result = db.prepare(`
  UPDATE books SET title = ?, author = ?
  WHERE id = ? AND owner_id = ?
`).run(input.title, input.author, id, user.id)

if (result.changes === 0) return problem(c, 404, 'Book not found')

One statement does the lookup, the check and the change. If changes is zero, either the book doesn’t exist or it belongs to someone else, and nothing happened. There’s no moment where the check is true and the write hasn’t happened yet.

Picking the status

Three codes cover the outcomes. 401 Unauthorized means “I don’t know who you are”: no credential, or an invalid one. 403 Forbidden means “I know who you are, and you can’t do this”. And sometimes 404 is the right answer for a 403, because “this exists but it’s not yours” tells a stranger that the ID is real. For books that leak doesn’t matter; for private documents it does.

Test with two users, not one. Create a book as X-Dev-User: alice, then try to update it as bob. The happy path always works. The bug worth finding is the other user getting through.

Add the development identity middleware and an owner_id column now, then restrict PUT and DELETE to the owner. When you swap the header for a real credential in the authentication course, nothing in the handlers has to change.

Lesson completed