JSON Web TokensMiddleware · Hono

Issue a signed token at login with sign() and guard the API with jwt(): the token carries who the caller is and what they may do, and the server trusts it without a lookup.

What you will learn

Read the theory for JSON Web Tokens

All Middleware lessons

All Hono courses

loading types…

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.

  1. Issue a token from POST /login with the claims sub (the username), role and exp set to EXPIRES_AT, and answer { "token": … }.
  2. Guard everything under /api with jwt(), using SECRET, HS256 and the realm cats-api.
  3. GET /api/me answers the verified claims.
  4. DELETE /api/cats/:id refuses any token whose role is not admin with 403 and { "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": pass alg: 'HS256'.
  • GET /api/me answers {} with a valid token: the handler answers a literal instead of c.get('jwtPayload').
  • The login's token differs from the expected one: a claim was added or exp is computed from the clock. For this lesson the payload is exactly sub, 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, nbf and iat and sets c.get('jwtPayload'); type it with JwtVariables<Claims>.
  • Refusals are 401 with WWW-Authenticate codes: invalid_request for a missing or malformed header, invalid_token for 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.