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" }

默认 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 auth

exp 字段是 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 app

6. 验证失败的不同情况

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 app

jwt() 在不同情况下抛出的错误消息:

  • 没有 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 auth

refresh 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() 函数支持。

这一节涉及到的几个层次:

  1. 基本验证jwt(&#123; secret &#125;) 从 Authorization 头读取并验证 token
  2. cookie 模式cookie 选项支持从 cookie 读取 token
  3. 类型安全:扩展 ContextVariableMapjwtPayload 有类型推导
  4. 用户查询jwt() 只验证签名,完整用户信息需要额外查数据库
  5. 主动失效:黑名单(有状态)或 refresh token(无状态)方案
  6. 密钥管理:通过环境变量管理,不硬编码

JWT 适合无状态、水平扩展的鉴权场景。如果需要细粒度的权限控制(例如用户级别的权限变更立即生效),需要搭配黑名单或缩短 access token 的有效期。

下一篇看 Bearer Token 认证——bearerAuth() 辅助函数的用法,以及 API Key 模式的实现。