Basic auth
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'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()
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.
- Guard everything under
/adminwith the credentialsadmin/secret, in the realmCats admin. - Guard
DELETE /cats/:idon that route only: accept any account in thekeeperslist throughverifyUser, refuse with the JSON{ "error": "Who are you?" }, and keep the accepted username on the context underuser. - 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/statsanswers200without credentials: the guard is registered below the route, so the route answered first, or it was registered on/adminwithout the/*. Middleware goes above the routes it wraps.- The realm is
Secure Areain the challenge:realmwas not passed, and the default applies. DELETE /cats/2refuses grace withUnauthorizedas text:invalidUserMessageis missing, or was given a string. An object is answered as JSON.byis missing from the delete's answer:onAuthSuccessdid notc.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 throughEnv.
Remember
basicAuth({ username, password, realm })on a prefix or a single route; it answers401withWWW-Authenticate: Basic realm="…"itself.verifyUser(username, password, c)replaces fixed credentials;invalidUserMessagemay be an object, answered as JSON.onAuthSuccess(c, username)plusc.set()hands the identity to the handler.401asks who you are;403is 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.