Skip to content

JWT

@vafast/jwt 基于 jose,提供 JWT 签发(sign)校验(verify)

jwt(options) 返回中间件:把 { sign, verify } 挂到请求对象的指定字段上(默认 req.jwt)。它本身不会拦截未登录请求,也不会自动注入 user——鉴权逻辑由你在 handler 或自定义中间件里完成。

先搞清几个概念(给新用户)

JWT 是什么?

JWT(JSON Web Token)是一段可在客户端与服务端之间传递的字符串令牌,常见形态:

text
xxxxx.yyyyy.zzzzz
 │      │      └─ 签名(Signature):用密钥算出,用于防篡改
 │      └─ 载荷(Payload):业务数据 + 标准声明,Base64URL 编码(可读,但不是加密)
 └─ 头(Header):算法等元信息,同样是 Base64URL

要点:

  • 可读 ≠ 安全:Payload 谁都能解码看到。密码、密钥、银行卡号不要放进去。
  • 签名防篡改:改了 Payload 后签名对不上,verify 会失败。
  • 不是自动登录中间件:本包只给你 sign / verify 工具;何时 401、如何读 Cookie / Bearer,要自己写。

JOSE / Claims / Header 是什么?

名词白话
JOSEJSON Object Signing and Encryption 的统称;JWT 签发校验常用其中的 JWS(签名)
Claim(声明)Payload 里的字段。有标准声明(如 exp 过期时间),也可以有业务字段(如 userId
Protected HeaderJWT 头部参数,告诉校验方「用什么算法、什么密钥」等。常见如 algtypkid

标准声明定义见 RFC 7519

本包怎么挂载?

typescript
const jwtMiddleware = jwt({ secret: '...', exp: '7d' })
// 之后在 handler 里:
await req.jwt.sign({ userId: 'u_1' })
await req.jwt.verify(token)

必须把中间件挂到会调用这些方法的路由上(或 server.use),否则 req.jwt 不存在。

安装

bash
npm install @vafast/jwt

快速开始

typescript
import { Server, defineRoute, defineRoutes, json, serve } from 'vafast'
import { jwt } from '@vafast/jwt'

const jwtMiddleware = jwt({
  secret: process.env.JWT_SECRET!,
  // 签发者:标记「这是谁发的 token」
  iss: 'my-app',
  // 默认过期时间:7 天(也可用秒数或 Unix 时间戳,见下方 exp)
  exp: '7d',
})

type JwtRequest = Request & {
  jwt: {
    sign: (data: { userId: string }) => Promise<string>
    verify: (token?: string) => Promise<{ userId?: string } | false>
  }
}

const routes = defineRoutes([
  defineRoute({
    method: 'POST',
    path: '/login',
    middleware: [jwtMiddleware],
    handler: async ({ req }) => {
      const token = await (req as JwtRequest).jwt.sign({ userId: 'u_1' })
      return json({ token })
    },
  }),
  defineRoute({
    method: 'GET',
    path: '/me',
    middleware: [jwtMiddleware],
    handler: async ({ req }) => {
      const token = req.headers.get('authorization')?.replace(/^Bearer\s+/i, '')
      const payload = await (req as JwtRequest).jwt.verify(token)
      if (!payload) return json({ error: 'Unauthorized' }, 401)
      return json({ userId: payload.userId })
    },
  }),
])

const server = new Server(routes)
serve({ fetch: server.fetch, port: 3000 })

说明:

  • 必须把 jwtMiddleware 挂到会调用 sign / verify 的路由(或 server.use
  • 包通过改写 Request 挂载方法,示例用 JwtRequest 做类型标注(不要依赖隐式 any

用法

基础用法

全局挂载一次,所有路由都能用 req.jwt

typescript
const server = new Server(routes)
server.use(jwt({ secret: process.env.JWT_SECRET!, exp: '1h' }))

或只挂在需要签发 / 校验的路由上:

typescript
defineRoute({
  method: 'GET',
  path: '/profile',
  middleware: [jwtMiddleware],
  handler: async ({ req }) => {
    // ...
  },
})

常见场景

1. TypeBox schema 约束 payload

schema 使用 TypeBox 做 payload 校验:

  • sign:不符合 → 抛错 JWT payload does not match schema
  • verify:不符合 → 返回 false(不抛错)
  • jose 附带的 iss / exp / iat 等标准 claims 不会误杀业务 schema(会同时试「完整 payload」与「去掉标准 claims 后的业务字段」)
typescript
import { Type } from 'vafast'
import { jwt } from '@vafast/jwt'

const Payload = Type.Object({
  userId: Type.String(),
  role: Type.Union([Type.Literal('user'), Type.Literal('admin')]),
})

const jwtMiddleware = jwt({
  secret: process.env.JWT_SECRET!,
  exp: '1h',
  schema: Payload,
})
typescript
import { json } from 'vafast'

defineRoute({
  method: 'POST',
  path: '/login',
  middleware: [jwtMiddleware],
  handler: async ({ req }) => {
    const token = await (req as JwtRequest).jwt.sign({ userId: 'u_1' })
    return json(
      { ok: true },
      200,
      {
        'Set-Cookie': `auth=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800`,
      },
    )
  },
})

浏览器场景更推荐配合 @vafast/cookie 管理 Cookie。

3. 在路由内自行做鉴权

给鉴权中间件标上上下文泛型,同一条叶子路由上挂 middleware 时,handler 能直接推断 user

typescript
import { defineMiddleware, err } from 'vafast'

type AuthUser = { id: string }

const requireUser = defineMiddleware<{ user: AuthUser }>(async (req, next) => {
  const header = req.headers.get('authorization')
  const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined
  const payload = await (req as JwtRequest).jwt.verify(token)
  if (!payload?.userId) throw err.unauthorized('请先登录')
  return next({ user: { id: payload.userId } })
})

defineRoute({
  method: 'GET',
  path: '/profile',
  middleware: [jwtMiddleware, requireUser],
  handler: ({ user }) => json({ id: user.id }), // user 有类型
})

也可先用 @vafast/bearer 提取 token,再 verify

4. 嵌套路由 / 拆文件时用 withContext(类型安全)

@vafast/jwt 本身不注入 user,只挂 sign / verify
withContext 解决的是另一件事:你自己的鉴权中间件用 next({ user }) 注入上下文后,父级挂中间件、子路由写在 children 或别的文件里时,TypeScript 推不出父级注入的字段。

场景要不要 withContext
只用 req.jwt.sign / verify不必。继续用 JwtRequest(或小 helper)标注即可
middleware: [jwt, requireUser] 写在同一条叶子路由通常不必。给 defineMiddleware<{ user }> 标泛型即可推断
组路由挂鉴权,叶子在 children / 多文件复用需要。用 withContext<{ user }>() 封装路由定义器
typescript
import { withContext, defineRoute, defineRoutes, defineMiddleware, err } from 'vafast'

type AuthUser = { id: string }

const requireUser = defineMiddleware<{ user: AuthUser }>(async (req, next) => {
  const token = req.headers.get('authorization')?.replace(/^Bearer\s+/i, '')
  const payload = await (req as JwtRequest).jwt.verify(token)
  if (!payload?.userId) throw err.unauthorized('请先登录')
  return next({ user: { id: payload.userId } })
})

// 纯类型包装,零运行时开销
const defineAuthedRoute = withContext<{ user: AuthUser }>()

const profileRoute = defineAuthedRoute({
  method: 'GET',
  path: '/profile',
  handler: ({ user }) => json({ id: user.id }), // 子路由也能拿到类型
})

defineRoutes([
  defineRoute({
    path: '/api',
    middleware: [jwtMiddleware, requireUser], // 运行时在父级注入
    children: [profileRoute],                 // 类型靠 withContext 接上
  }),
])

更完整的说明见 最佳实践 · withContext中间件 · withContext
若走独立认证服务,可直接用 @vafast/auth-middleware 自带的 defineAuthRouteWithApp 等,不必手写。

5. 多套密钥(access / refresh)

name 不同即可挂多实例:

typescript
const accessJwt = jwt({
  name: 'accessToken',
  secret: process.env.JWT_ACCESS_SECRET!,
  exp: '15m',
})

const refreshJwt = jwt({
  name: 'refreshToken',
  secret: process.env.JWT_REFRESH_SECRET!,
  exp: '7d',
})

defineRoute({
  method: 'POST',
  path: '/auth/refresh',
  middleware: [accessJwt, refreshJwt],
  handler: async ({ req }) => {
    // 挂载后字段名 = name:
    // (req as Request & { accessToken: ...; refreshToken: ... })
  },
})

6. 配置标准声明(issuer / audience / 过期)

typescript
const jwtMiddleware = jwt({
  secret: process.env.JWT_SECRET!,
  // 谁签发的
  iss: 'https://api.example.com',
  // 签发给谁用(可写成数组)
  aud: 'https://app.example.com',
  // 默认主体(常在 sign 时按用户覆盖)
  sub: undefined,
  // 1 小时后过期
  exp: '1h',
  // 立即生效(也可写未来时间表示「到点才能用」)
  nbf: '0s',
})

// sign 时可覆盖默认 claims:
await req.jwt.sign({
  userId: 'u_1',
  sub: 'u_1',
  aud: 'admin-console',
  exp: '5m',
})

API

导出

导出说明
jwt工厂函数:传入配置,返回中间件
defaultjwt
JWTOptionjwt(...) 的配置类型(含业务项 + 标准 claims + header)
JWTPayloadSpec标准 Payload 声明类型(iss / sub / aud / …)
JWTHeaderParametersProtected Header 类型(alg / typ / kid / …)

调用形式

typescript
jwt(options: JWTOption)

配置可分成三块理解:本包业务选项标准 Payload 声明(Claims)JOSE Header 参数


本包业务选项

参数类型默认说明
secretstring | Uint8Array | JWK必填。签名 / 验签密钥。空值(含空字符串)会立刻抛 Secret can't be empty。若传 string,内部会先 new TextEncoder().encode(secret) 再交给 jose。生产环境请用足够长的随机串,并只放环境变量。
namestring'jwt'挂到 Request 上的字段名。例如 name: 'accessToken' → 使用 req.accessToken.sign / verify。多套 token(access / refresh)时靠不同 name 区分。
schemaTSchema(TypeBox)可选。约束业务 payload 形状。sign 校验失败抛错;verify 校验失败返回 false

标准 Payload 声明(Claims)

这些字段既可在 jwt({ ... }) 里设默认值,也可在每次 sign(data) 时传入并覆盖默认值(同名字段以 data 为准)。

参数类型默认含义(白话)详细说明
issstringIssuer,签发者标识「谁签发了这个 token」。常见写法:应用名('my-app')或服务 URL('https://api.example.com')。多服务互通时,接收方可用它判断是否信任该来源。见 RFC 7519 §4.1.1
substringSubject,主体标识「这个 token 是关于谁的」。常见放用户 ID('u_123')。与业务字段 userId 可以并存:有人只放 sub,有人两者都放——选一种约定并坚持即可。见 RFC 7519 §4.1.2
audstring | string[]Audience,受众标识「这个 token 签发给谁用」。可以是单个字符串,或字符串数组(多个合法接收方)。例如 API 网关只接受 aud === 'api' 的 token,避免把给管理后台的 token 误用到开放 API。见 RFC 7519 §4.1.3
jtistringJWT ID,令牌唯一编号给每个 token 一个唯一 ID。常用于:登出黑名单、一次性 token、审计追踪。本包不内置黑名单存储,需要你自己在服务端记录已吊销的 jti。见 RFC 7519 §4.1.7
nbfstring | numberNot Before,生效起点「在此之前不能用」。未到时间则校验失败。适合「预约生效」或给时钟偏差留缓冲(如 '0s' 表示立即)。格式见下方「时间怎么写」。见 RFC 7519 §4.1.5
expstring | numberExpiration Time,过期时间「过了这个点就失效」。强烈建议始终配置。access token 常用 '15m' / '1h';refresh 可用 '7d'。过期后 verify 返回 false。见 RFC 7519 §4.1.4
iatboolean默认写入当前时间Issued At,签发时间控制是否写入 iat 声明。实现上:只要配置侧或 sign 入参没有明确关掉(!== false),就会 setIssuedAt(new Date()) 写入当前时间。一般保持默认即可;审计、排序、排查「何时签发」时有用。见 RFC 7519 §4.1.6

时间怎么写?(exp / nbf

jose 的 setExpirationTime / setNotBefore 支持多种写法,常见:

写法例子含义
相对时长字符串'15m''1h''7d'从现在起 15 分钟 / 1 小时 / 7 天后
秒数字符串'60s''0s'60 秒后 / 立即
数字Unix 时间戳(秒)或 jose 接受的数值形式绝对时间点

新手建议:生产配置用相对时长('15m''7d'),可读且不易算错绝对时间戳。

Claims 覆盖规则(重要)

typescript
jwt({ iss: 'my-app', exp: '1h' })

// 未传 iss/exp → 用配置默认值
await req.jwt.sign({ userId: 'u_1' })

// 传入同名字段 → 覆盖默认值
await req.jwt.sign({ userId: 'u_1', iss: 'admin-service', exp: '5m' })

nbf / exp:仅当「data 或默认配置里至少有一边有值」时才会写入 JWT。
iat:见上表,默认会写入当前时间。


JOSE Header 参数(Protected Header)

Header 描述「这个 JWT 怎么签、用哪把钥匙」,多数应用只需关心 algtyp。其余字段在密钥轮换、多密钥、证书体系里才会用到。

参数类型默认含义(白话)详细说明
algstring'HS256'Algorithm,签名算法默认对称算法 HS256(用同一份 secret 签发和校验),上手最简单。非对称算法(如 RS256)需要公私钥对,适合「多方校验、私钥不出签发服务」的场景。算法与密钥类型必须匹配,否则签发/校验会失败。参见 jose 算法说明
typstring'JWT'Type,令牌类型通常保持 'JWT'。告诉接收方「这是 JWT」。一般不用改。
kidstringKey ID,密钥编号当你有多把密钥轮换时,用 kid 标明「这枚 token 是用哪把钥匙签的」,校验方按 kid 选对公钥/密钥。单密钥场景可忽略。
jwkJWKJSON Web Key把公钥以 JWK 对象形式放进 header(少见;更常见是放在 JWKS 端点)。高级集成用。
jkustringJWK Set URL指向一组公钥的 URL(JWKS)。接收方可拉取该 URL 找密钥。需信任该 URL,注意 SSRF / 供应链风险。
x5cstring[]X.509 证书链以证书链形式携带公钥材料。企业 PKI / 部分联邦登录场景会用到。
x5tstringX.509 证书指纹(SHA-1)证书的拇指指纹,用于快速标识证书。
x5ustringX.509 URL指向证书(链)的 URL。
ctystringContent Type,内容类型标明 Payload 内容类型。嵌套 JWT 等特殊场景才需要。
critstring[]Critical,关键扩展头列出「接收方必须理解,否则应拒绝」的扩展 header 名。用于强制扩展语义。
b64trueRFC 7797 载荷编码开关RFC 7797 相关:影响 Payload 是否按常规 Base64URL 编码。普通 JWT 登录场景不要动;保持默认即可。

未设置的可选 header 字段不会强行写入;alg / typ 有安全默认值。


挂载后的方法

创建中间件并挂到请求后,req[name](默认 req.jwt)上有:

sign(data) => Promise<string>

签发一枚 JWT 字符串。

步骤:

  1. 若配置了 schema,先用 Value.Check 校验 data;失败抛 JWT payload does not match schema
  2. 合并默认 claims / header 与 data(claims 以 data 优先)
  3. 按规则写入 nbf / exp / iat
  4. secret 签名,返回 JWT 字符串

data 类型:业务字段 + 可选标准 claims(iss / sub / aud / jti / nbf / exp / iat)。

verify(jwt?) => Promise<payload | false>

校验一枚 JWT。

情况返回值
未传 token / 空字符串false
签名错误、过期、nbf 未到、格式损坏等 jose 抛错false不抛给业务
配置了 schema 且业务字段不匹配false
成功payload 对象(业务字段 + 标准 claims)

务必在业务里显式处理 false(返回 401 或抛 err.unauthorized),不要假设「有 token 就一定有效」。

最佳实践

  1. secret 只放环境变量;生产用足够长的随机串(例如 32+ 字节)。不要提交到 Git。
  2. access 短过期,refresh 长过期;两套 token 用不同 name + 不同 secret
  3. Payload 只放必要字段(如 userIdrole)。敏感数据放服务端会话 / 数据库。
  4. schema 保证签发与校验形状一致,减少「字段漂移」。
  5. Cookie 存 JWT 时加 HttpOnly + Secure + SameSite;跨站场景再仔细评估 SameSite=None
  6. 需要吊销能力时,给 token 加 jti,并在服务端维护黑名单 / 版本号。
  7. 多服务架构建议明确配置 iss / aud,避免 token 被串用到错误服务。
  8. 自写 next({ user }) 且路由拆到 children / 多文件时,用 withContext 保证 handler 类型;不要把 withContext 误当成 jwt 包的必需步骤。

注意事项

  • 本包只提供工具,自动 401,注入用户上下文
  • verify 失败返回 false,不会抛错;请在业务里显式处理
  • secret 为空会在创建中间件时立即抛错(不是等第一次请求)
  • Payload 可被解码阅读,签名只防篡改,不防窥视——HTTPS 仍然必要
  • req.jwt 的类型需自行扩展(如示例中的 JwtRequest);withContext 不能替代这一步
  • 默认算法是 HS256(对称密钥);改 alg 时请确认 secret / 密钥材料与算法匹配

相关链接