JWT 中间件
要点
- Hono 内置
jwt()中间件验证Authorization: Bearer <token>头 - JWT 验证只做签名校验,不包含用户查询逻辑——需要自己从数据库加载用户
- 验证通过后通过
c.set()把 payload 传给下游路由 jwt()支持从 header 或 cookie 读取 token,适配不同的前端存储方式- token 过期、签名错误、格式不合法都会返回 401,但错误信息有细微差异
- JWT 适合无状态鉴权;需要主动失效的场景需要搭配黑名单或 refresh token
1. JWT 基础
JWT(JSON Web Token)由三部分组成,用 . 连接:
// token.txt
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxIiwicm9sZSI6ImFkbWluIn0.SflKxwRJSwQKBPx0hQ- 第一部分:header,描述签名算法(如 HS256)
- 第二部分:payload,携带的声明数据(如 userId、role)
- 第三部分:signature,用密钥对前两部分签名
服务端验证 JWT 时,用同一份密钥重新计算签名,与 token 里的签名对比。一致说明 token 没被篡改。
JWT 的 payload 是 base64 编码,不是加密——任何人都能解码读到里面的内容。所以 payload 里不应该放敏感信息(密码、身份证号)。
2. 基本用法
Hono 内置的 jwt() 中间件负责签名验证:
// src/index.ts
import { Hono } from 'hono'
import { jwt } from 'hono/jwt'
const app = new Hono()
// 验证 JWT
app.use('/api/*', jwt({
secret: 'my-secret-key',
}))
app.get('/api/profile', (c) => {
// jwt() 验证通过后,payload 被存入 context
const payload = c.get('jwtPayload')
return c.json({ payload })
})
export default app请求带有效 token:
curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \
http://localhost:8787/api/profile请求不带 token 或 token 无效,jwt() 直接返回 401。
jwt() 只验证签名和过期时间。验证通过后,c.get('jwtPayload') 返回的是 token 里的 payload 对象,例如 { userId: "1", role: "admin" }。
3. 从 header 和 cookie 双模式读取
默认 jwt() 从 Authorization: Bearer <token> 头读取 token。如果前端把 token 存在 cookie 里,需要配置 cookie 选项:
// src/index.ts
import { Hono } from 'hono'
import { jwt } from 'hono/jwt'
const app = new Hono()
app.use('/api/*', jwt({
secret: 'my-secret-key',
cookie: 'access_token', // 从名为 access_token 的 cookie 读取
}))
app.get('/api/profile', (c) => {
const payload = c.get('jwtPayload')
return c.json({ payload })
})
export default app也可以同时支持两种方式——header 优先,cookie 兜底。Hono 的 jwt() 不支持这种混合模式,需要自己写一个:
// src/middleware/jwt-flexible.ts
import { createMiddleware } from 'hono/factory'
import { sign, verify, decode } from 'hono/jwt'
type JwtPayload = {
userId: string
role: string
exp: number
}
export const jwtFlexible = createMiddleware<{ Variables: { jwtPayload: JwtPayload } }>(
async (c, next) => {
// 优先从 header 读取
let token = c.req.header('Authorization')?.replace('Bearer ', '')
// 兜底从 cookie 读取
if (!token) {
token = c.req.cookie('access_token')
}
if (!token) {
return c.json({ error: 'Unauthorized' }, 401)
}
try {
const payload = await verify(token, 'my-secret-key')
c.set('jwtPayload', payload as JwtPayload)
await next()
} catch {
return c.json({ error: 'Invalid token' }, 401)
}
}
)这种混合模式在渐进式迁移(从 cookie 迁移到 header,或反之)时比较常见。
4. 签发 token
jwt() 中间件只负责验证,签发 token 需要用到 sign() 函数:
// src/routes/auth.ts
import { Hono } from 'hono'
import { sign } from 'hono/jwt'
const auth = new Hono()
auth.post('/login', async (c) => {
const { email, password } = await c.req.json()
// 验证用户名密码(略)
const user = { id: '1', email, role: 'admin' }
// 签发 token,有效期 24 小时
const token = await sign(
{
userId: user.id,
role: user.role,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24, // 24 小时后过期
},
'my-secret-key',
)
return c.json({ token })
})
export default authexp 字段是 Unix 时间戳(秒)。jwt() 验证时会检查当前时间是否超过 exp,超过则返回 401。
5. 类型安全的 jwtPayload
默认 c.get('jwtPayload') 返回的类型是 any。通过扩展 ContextVariableMap 可以获得类型推导:
// src/types/hono.ts
import 'hono'
type JwtPayload = {
userId: string
role: string
exp: number
}
declare module 'hono' {
interface ContextVariableMap {
jwtPayload: JwtPayload
}
}// src/index.ts
import { Hono } from 'hono'
import { jwt } from 'hono/jwt'
import './types/hono'
const app = new Hono()
app.use('/api/*', jwt({ secret: 'my-secret-key' }))
app.get('/api/profile', (c) => {
// payload 类型自动推导为 JwtPayload
const payload = c.get('jwtPayload')
// payload.userId、payload.role 都有类型提示
return c.json({ payload })
})
export default app6. 验证失败的不同情况
jwt() 验证失败时,根据原因返回不同的响应:
// src/index.ts
import { Hono } from 'hono'
import { jwt } from 'hono/jwt'
const app = new Hono()
app.use('/api/*', jwt({ secret: 'my-secret-key' }))
// 自定义错误处理,覆盖 jwt() 的默认响应
app.onError((err, c) => {
if (err.message === 'Unauthorized') {
return c.json({ error: 'Token is invalid or expired' }, 401)
}
return c.json({ error: 'Internal Server Error' }, 500)
})
export default appjwt() 在不同情况下抛出的错误消息:
- 没有 Authorization 头:
Unauthorized - token 格式错误:
Invalid token - 签名不匹配:
Invalid token - token 已过期:
Token expired
如果想区分这些情况,可以不用 jwt() 中间件,自己调用 verify() 并捕获异常:
// src/middleware/custom-jwt.ts
import { createMiddleware } from 'hono/factory'
import { verify } from 'hono/jwt'
export const customJwt = createMiddleware(async (c, next) => {
const authHeader = c.req.header('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ error: 'Missing token' }, 401)
}
const token = authHeader.replace('Bearer ', '')
try {
const payload = await verify(token, 'my-secret-key')
c.set('jwtPayload', payload)
await next()
} catch (err) {
const message = (err as Error).message
if (message.includes('exp')) {
return c.json({ error: 'Token expired' }, 401)
}
return c.json({ error: 'Invalid token' }, 401)
}
})7. 与用户查询配合
jwt() 验证通过后,c.get('jwtPayload') 里只有 token 自带的字段(userId、role 等)。如果需要完整的用户对象(用户名、头像、邮箱),还需要查数据库:
// src/middleware/load-user.ts
import { createMiddleware } from 'hono/factory'
type User = {
id: string
name: string
email: string
role: string
}
export const loadUser = createMiddleware<{ Variables: { user: User } }>(
async (c, next) => {
const payload = c.get('jwtPayload') as { userId: string }
if (!payload?.userId) {
return c.json({ error: 'Invalid token' }, 401)
}
// 查数据库
const user = await db.user.findUnique({
where: { id: payload.userId },
})
if (!user) {
return c.json({ error: 'User not found' }, 401)
}
c.set('user', user)
await next()
}
)// src/index.ts
import { Hono } from 'hono'
import { jwt } from 'hono/jwt'
import { loadUser } from './middleware/load-user'
const app = new Hono()
// jwt() 先验证签名
app.use('/api/*', jwt({ secret: 'my-secret-key' }))
// loadUser() 再查数据库
app.use('/api/*', loadUser())
app.get('/api/profile', (c) => {
// 现在有完整的 user 对象
const user = c.get('user')
return c.json({ user })
})
export default app两个中间件的职责清晰:jwt() 负责密码学验证,loadUser() 负责业务层数据加载。
8. token 主动失效
JWT 是无状态的——服务端不记录已签发的 token。这意味着一旦 token 签发,在过期前一直有效。如果需要主动使 token 失效(用户登出、密码修改、权限变更),有两种常见方案:
8.1 黑名单
每次用户登出时,把 token 的 jti(JWT ID)加入黑名单。jwt() 验证后再检查黑名单:
// src/middleware/jwt-with-revocation.ts
import { createMiddleware } from 'hono/factory'
import { verify, decode } from 'hono/jwt'
// 黑名单可以用 Redis、内存 Map 等
const revokedTokens = new Set<string>()
export const jwtWithRevocation = createMiddleware(async (c, next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) {
return c.json({ error: 'Unauthorized' }, 401)
}
try {
const payload = await verify(token, 'my-secret-key')
const jti = (payload as { jti?: string }).jti
if (jti && revokedTokens.has(jti)) {
return c.json({ error: 'Token revoked' }, 401)
}
c.set('jwtPayload', payload)
await next()
} catch {
return c.json({ error: 'Invalid token' }, 401)
}
})
// 登出时加入黑名单
export function revokeToken(jti: string) {
revokedTokens.add(jti)
}黑名单的缺点是占用存储空间。可以用 Redis 的 TTL 功能自动清理过期 token 的记录。
8.2 refresh token
短生命周期的 access token(15 分钟)+ 长生命周期的 refresh token(7 天)。access token 过期后用 refresh token 换新 token。用户登出时只需清除 refresh token,access token 会在 15 分钟内自然过期。
// src/routes/auth.ts
import { Hono } from 'hono'
import { sign } from 'hono/jwt'
const auth = new Hono()
// 登录:签发 access + refresh token
auth.post('/login', async (c) => {
const { email, password } = await c.req.json()
const user = { id: '1', role: 'admin' }
const accessToken = await sign(
{
userId: user.id,
role: user.role,
exp: Math.floor(Date.now() / 1000) + 15 * 60, // 15 分钟
},
'my-secret-key',
)
const refreshToken = await sign(
{
userId: user.id,
type: 'refresh',
exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 天
},
'my-refresh-secret-key', // refresh token 用不同的密钥
)
return c.json({ accessToken, refreshToken })
})
// 刷新:用 refresh token 换新的 access token
auth.post('/refresh', async (c) => {
const { refreshToken } = await c.req.json()
try {
const payload = await verify(refreshToken, 'my-refresh-secret-key')
if (payload.type !== 'refresh') {
return c.json({ error: 'Invalid refresh token' }, 401)
}
const newAccessToken = await sign(
{
userId: payload.userId,
role: 'admin',
exp: Math.floor(Date.now() / 1000) + 15 * 60,
},
'my-secret-key',
)
return c.json({ accessToken: newAccessToken })
} catch {
return c.json({ error: 'Invalid refresh token' }, 401)
}
})
export default authrefresh token 方案不需要黑名单,服务端不需要维护任何状态。缺点是 access token 在过期前的 15 分钟内仍然有效,无法立即失效。
9. 密钥管理
jwt() 和 sign() 都需要密钥。密钥不应该硬编码在源码里:
// src/config/env.ts
export const JWT_SECRET = process.env.JWT_SECRET
if (!JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is required')
}// src/index.ts
import { Hono } from 'hono'
import { jwt } from 'hono/jwt'
import { JWT_SECRET } from './config/env'
const app = new Hono()
app.use('/api/*', jwt({ secret: JWT_SECRET }))
export default app生产环境的密钥应该足够长(至少 256 位 / 32 字节),并使用安全的随机生成器。不要用弱密码或默认值。
总结
JWT 中间件是 Hono 鉴权方案的核心组件。jwt() 中间件处理签名验证和过期检查,签发和刷新逻辑由 sign() 和 verify() 函数支持。
这一节涉及到的几个层次:
- 基本验证:
jwt({ secret })从 Authorization 头读取并验证 token - cookie 模式:
cookie选项支持从 cookie 读取 token - 类型安全:扩展
ContextVariableMap让jwtPayload有类型推导 - 用户查询:
jwt()只验证签名,完整用户信息需要额外查数据库 - 主动失效:黑名单(有状态)或 refresh token(无状态)方案
- 密钥管理:通过环境变量管理,不硬编码
JWT 适合无状态、水平扩展的鉴权场景。如果需要细粒度的权限控制(例如用户级别的权限变更立即生效),需要搭配黑名单或缩短 access token 的有效期。
下一篇看 Bearer Token 认证——bearerAuth() 辅助函数的用法,以及 API Key 模式的实现。