Files
What you'll learn
- Sign a token with sign(payload, secret) and read its three parts
- Guard routes with jwt({ secret, alg }) and read the verified claims from c.get('jwtPayload')
- Tell the refusals apart: no token, a token that fails verification, and a valid token whose claims are not enough
JSON Web Tokens
A bearer token from the last lesson is a secret the server recognises. To know anything about its holder, who they are, what they may do, the server has to look the token up somewhere, on every request. A JSON Web Token puts those facts inside the token and signs them, so any server holding the key can verify the claims without a lookup, which is what makes JWTs the common currency between a login service and the APIs behind it. Hono ships both halves in hono/jwt: sign to issue a token and the jwt middleware to guard routes with one.
What a token is
Three base64url parts joined by dots: a header naming the algorithm, {"alg":"HS256","typ":"JWT"}; the payload, an object of claims; and a signature over the first two, computed with the secret. Anyone can decode the payload, it is not encrypted, but nobody without the secret can change it and keep the signature valid. A few claim names are standard and the middleware checks them: exp, the expiry as seconds since 1970, after which the token is refused; nbf, not before; iat, issued at, which may not be in the future. sub, the subject, is the conventional place for the user's id, and anything else, a role, a plan, is yours to add.
Issuing one
import { sign } from 'hono/jwt';
const token = await sign({ sub: 'user123', role: 'admin', exp: Math.floor(Date.now() / 1000) + 60 * 5 }, 'mySecretKey');
sign(payload, secret, alg?) is async because the signature comes from the Web Crypto API; HS256, an HMAC with a shared secret, is the default. The token goes back to the client, which sends it as Authorization: Bearer <token> from then on. The secret must never leave the server, and a real one is long and random; the lesson's cats-secret is short so it can be read.
The same payload and secret always give the same token, which is why this lesson fixes exp to a date in 2100: the graded requests can carry the tokens the login route issues. A real app computes exp from the current time, as above.
Guarding with one
import { jwt, type JwtVariables } from 'hono/jwt';
const app = new Hono<{ Variables: JwtVariables }>();
app.use('/auth/*', jwt({ secret: 'it-is-very-secret', alg: 'HS256' }));
app.get('/auth/page', (c) => {
const payload = c.get('jwtPayload');
return c.json(payload);
});
jwt() needs both secret and alg; leaving alg out throws when the middleware is built. It reads the Authorization header (or a cookie, with the cookie option, or another header with headerName), verifies the signature and the time claims, and puts the payload on the context as jwtPayload. JwtVariables<T> types it; with a Claims type as its argument, c.get('jwtPayload').role is typed too.
The refusals all answer 401 Unauthorized and differ in the WWW-Authenticate header, which carries the realm, an error code and a description. No header at all is invalid_request with no authorization included in request; a header without the Bearer scheme is invalid_request with invalid credentials structure; and every failed verification, a wrong secret, a tampered signature, an expired exp, a future nbf, a different algorithm, a string that is not a token, is invalid_token with token verification failure. That last one is deliberate: the reason a token failed is a fact about the server's key, not something to hand a caller. The realm option names the realm; without it the middleware uses the request's URL.
Verified is not authorised
A valid token proves who is asking and carries what the login service said about them. What they may do here is still the handler's decision, read from the claims: a role that is not admin gets a 403, after the middleware let the request through. The claims were signed by you, so trusting them is right; treating "has a token" as "may do anything" is the mistake.
Your task
The login route finds the user and issues nothing.
- Issue a token from
POST /loginwith the claimssub(the username),roleandexpset toEXPIRES_AT, and answer{ "token": … }. - Guard everything under
/apiwithjwt(), usingSECRET,HS256and the realmcats-api. GET /api/meanswers the verified claims.DELETE /api/cats/:idrefuses any token whose role is notadminwith403and{ "error": "Admins only" }.
Sign in as ada / catnip from the request bar, paste the token into an Authorization header on GET /api/me, then change one character of it.
When it fails
- The app fails to start with
JWT auth middleware requires options for "alg": passalg: 'HS256'. GET /api/meanswers{}with a valid token: the handler answers a literal instead ofc.get('jwtPayload').- The login's token differs from the expected one: a claim was added or
expis computed from the clock. For this lesson the payload is exactlysub,role,exp. - Grace can delete: the role check is missing, or compares against the wrong claim. The role is in the payload, not the header.
Remember
- A JWT is header, claims and signature;
sign(payload, secret)issues one, and the claims are readable by anyone but forgeable by no one. jwt({ secret, alg })verifies signature,exp,nbfandiatand setsc.get('jwtPayload'); type it withJwtVariables<Claims>.- Refusals are
401withWWW-Authenticatecodes:invalid_requestfor a missing or malformed header,invalid_tokenfor every failed verification. - Authorisation stays in the handler, read from the claims.
Stuck? Show a hint
sign() and jwt() come from hono/jwt; the middleware needs both secret and alg ('HS256'). A login route finds the user, signs { sub, role, exp } and answers { token }. Type the app with JwtVariables<Claims> so c.get('jwtPayload') is typed, and let the delete handler read the role from it.
Press Run tests to start the app. Its log appears here.Graded endpoints
No user matches: no token is issued
sign({ sub, role, exp }, secret) with a fixed expiry gives the same token every time, which is what makes it gradeable
jwt() refuses with 401 and a challenge saying no authorization was included
Anything that fails verification, a made-up string, a bad signature, an expired token, is one refusal: invalid_token
Signed with the right secret but its exp is in the past: the same invalid_token
Verified: the handler answers the claims from c.get('jwtPayload')
A valid token whose role is keeper: the handler reads the claim and refuses with 403
Past the guard and the role check, the handler's 404
An admin's token: the cat is removed
Any valid token may read; Tom is gone