Sign-in with JWT
Right now anyone can create or delete a post. APIs usually rely on JWT — a signed token, a string in which the server records who the user is and adds a signature made with a secret key. The client receives the token on sign-in and sends it with every request; the server checks the signature and keeps no sessions.
The auth module
The secret comes from the JWT_SECRET variable in the .env file. Create src/auth.ts:
// src/auth.ts
import { jwt } from 'hono/jwt'
const secret = process.env.JWT_SECRET
if (!secret) {
throw new Error('JWT_SECRET is not set')
}
export const jwtSecret = secret
export const requireAuth = jwt({ secret, alg: 'HS256' })If the variable is missing, the server refuses to start and says so. requireAuth is middleware that lets a request through only with a valid token. HS256 is the algorithm that signs with a shared secret.
Issuing a token
Create src/routes/auth.ts with a sign-in route. For simplicity the admin password also lives in .env as ADMIN_PASSWORD; in a real project users are stored in the database with password hashes:
// src/routes/auth.ts
import { Hono } from 'hono'
import { sign } from 'hono/jwt'
import { jwtSecret } from '../auth.js'
const auth = new Hono()
auth.post('/login', async (c) => {
const { username, password } = await c.req.json<{ username: string; password: string }>()
if (username !== 'anna' || password !== process.env.ADMIN_PASSWORD) {
return c.json({ error: 'Wrong username or password' }, 401)
}
const token = await sign(
{ sub: username, exp: Math.floor(Date.now() / 1000) + 60 * 60 },
jwtSecret,
)
return c.json({ token })
})
export default authMount the group in src/index.ts: app.route('/auth', auth). The exp field is the token's expiry time in seconds: after an hour the token is no longer accepted.
Protecting routes
Add requireAuth to the routes that change data in src/routes/posts.ts:
// src/routes/posts.ts
import { requireAuth } from '../auth.js'
posts.post('/', requireAuth, zValidator('json', postSchema), (c) => {
const user = c.get('jwtPayload')
console.log(`Post created by ${user.sub}`)
// ...create the post as in the previous lesson
return c.json({ author: user.sub }, 201)
})Do the same for put and delete. c.get('jwtPayload') returns the data from the token. Reading the list stays open.
Checking
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "anna", "password": "password-from-env"}'The response is {"token":"eyJ..."}. A request without the token gets 401; with the token it goes through:
curl -X POST http://localhost:3000/posts \
-H "Authorization: Bearer eyJ..." \
-H "Content-Type: application/json" \
-d '{"title": "A protected post", "body": "Text"}'Replace eyJ... with the whole token you received.
Common mistake
Putting secret data into the token, such as a password or an address. The signature protects the token from forgery but does not hide its content: any part of it can be read by decoding base64. Keep only the user id, the role and the expiry time in the token.