Basic authMiddleware · Hono

Put a username and password in front of the admin routes with basicAuth(), on a path prefix and on a single route, and let the handler know who signed in.

What you will learn

Read the theory for Basic auth

All Middleware lessons

All Hono courses

loading types…

What you'll learn

  • Guard a path prefix with basicAuth({ username, password, realm }) and read the 401 and WWW-Authenticate challenge a browser reacts to
  • Check credentials your own way with verifyUser, and answer refusals with your own message
  • Hand the authenticated user to the handler with onAuthSuccess and c.set()

Basic auth

The cats API has an admin corner now, a stats route and a delete that should not be open to anyone with the URL. The oldest way to close it is still a good one for internal tools and admin pages: HTTP Basic authentication. The client sends Authorization: Basic <base64 of user:password> on every request; a browser that gets a 401 with a WWW-Authenticate: Basic challenge shows its login dialog and does that for you. Hono's basicAuth middleware implements the whole exchange, and this lesson uses it two ways: on a prefix, and on one route with your own check.

Guarding a prefix

import { basicAuth } from 'hono/basic-auth';

app.use('/auth/*', basicAuth({ username: 'hono', password: 'acoolproject' }));

app.get('/auth/page', (c) => c.text('You are authorized'));

Registered above the routes, the middleware sees every request under /auth. Without an Authorization header, or with the wrong credentials, it answers itself: 401, the body Unauthorized, and the header WWW-Authenticate: Basic realm="Secure Area". The realm option changes the quoted name, which browsers show in the login dialog and use to remember which credentials belong where. With the right credentials it calls next() and the handler runs, never seeing the header at all.

The comparison is constant-time, so a wrong password takes as long to refuse as a nearly right one; that is one of the reasons to use the middleware rather than an if on the header.

Guarding one route

Like any middleware, basicAuth() also goes between a path and its handler, to protect one route and one method:

app.delete('/auth/page', basicAuth({ username: 'hono', password: 'acoolproject' }), (c) => c.text('Page deleted'));

Your own check

A fixed username and password cover one account. For several, or for credentials that live somewhere else, verifyUser replaces them:

basicAuth({
  verifyUser: (username, password, c) => username === 'dynamic-user' && password === 'hono-password',
})

It receives the decoded username and password and returns whether to accept, synchronously or as a promise, so it can look the user up. invalidUserMessage replaces the Unauthorized body; an object is answered as JSON with the right content type, which suits an API whose other errors are JSON. Several fixed accounts can also be passed as extra arguments, basicAuth({ username, password, realm }, { username, password }, …).

Telling the handler who it was

Authentication is the middleware's job; what the handler does with the identity is its own. onAuthSuccess(c, username) runs once the user is accepted, and is the place to put the name on the context:

basicAuth({
  username: 'hono',
  password: 'acoolproject',
  onAuthSuccess: (c, username) => c.set('username', username),
})

app.get('/auth/page', (c) => c.text(`Hello, ${c.get('username')}!`));

The Variables generic from Basics applies: the app declares { Variables: { username: string } }, or c.set on that key is a type error.

Authentication is not authorisation

Knowing who is asking says nothing about what they may do. A keeper who signs in correctly can still be refused a particular cat, and that refusal is the handler's, 403, after the middleware has done its part. The two answers differ on purpose: 401 means "tell me who you are", 403 means "I know who you are, and no".

Your task

The API in the editor has an admin stats route and a delete route, both open.

  1. Guard everything under /admin with the credentials admin / secret, in the realm Cats admin.
  2. Guard DELETE /cats/:id on that route only: accept any account in the keepers list through verifyUser, refuse with the JSON { "error": "Who are you?" }, and keep the accepted username on the context under user.
  3. The delete handler answers { "removed": id, "by": <username> }.

Send GET /admin/stats from the request bar without a header first, and read the response headers: the WWW-Authenticate line is what makes a browser ask.

When it fails

  • GET /admin/stats answers 200 without credentials: the guard is registered below the route, so the route answered first, or it was registered on /admin without the /*. Middleware goes above the routes it wraps.
  • The realm is Secure Area in the challenge: realm was not passed, and the default applies.
  • DELETE /cats/2 refuses grace with Unauthorized as text: invalidUserMessage is missing, or was given a string. An object is answered as JSON.
  • by is missing from the delete's answer: onAuthSuccess did not c.set() the name, or the handler read a different key. The middleware and the app must agree on the variable, and TypeScript holds them to it through Env.

Remember

  • basicAuth({ username, password, realm }) on a prefix or a single route; it answers 401 with WWW-Authenticate: Basic realm="…" itself.
  • verifyUser(username, password, c) replaces fixed credentials; invalidUserMessage may be an object, answered as JSON.
  • onAuthSuccess(c, username) plus c.set() hands the identity to the handler.
  • 401 asks who you are; 403 is the handler saying no to someone it knows.
Stuck? Show a hint

basicAuth() is middleware: app.use('/admin/*', basicAuth({ … })) guards a prefix, and putting it between the path and the handler guards one route. verifyUser receives the decoded username and password and returns whether they match; invalidUserMessage may be an object, which is answered as JSON. onAuthSuccess(c, username) runs once the user is accepted, which is where c.set('user', username) belongs.