前一阵给内部工具项目加登录功能,第一次在 Cloudflare Workers 上完整走了一遍 JWT 认证链路。网上关于 Hono 的示例其实不少,但真正能从登录签发、请求拦截、验签失败处理到 token 信息读取完整讲通的并不多。这篇文章把这次实践沉淀的方案彻底展开,包含所有关键代码、设计思路、踩坑记录和排查思路,目标是一篇拿过去就能直接照着改的实战笔记。
先说清楚这套东西到底解决了什么问题。Cloudflare Workers 是跑在边缘节点上的无服务器 JavaScript 运行时,Hono 是一个专门为这类环境设计的小型 Web 框架,JWT 则是无状态认证的标准方案。三者的组合很适合做 API 网关、BFF 层、团队内部小工具的鉴权后端。适合谁来读?有一定 JavaScript 基础,想在边缘计算环境做认证,或者已经在用 Workers 但不知道怎么组织路由和鉴权逻辑的开发者。下面按我实际推进的顺序来写。
1. 为什么我看好 Cloudflare Workers + Hono 这套组合
1.1 边缘计算场景下的认证痛点
刚接触 Workers 的人容易把它当普通 Node.js 服务器来用,实际上两者的运行环境差很多。Workers 跑在 V8 isolate 里,没有传统意义上的 Node 事件循环,很多东西比如 Node 内置模块不一定可用,代码大小、启动时间都有限制。第一次做认证功能时最直观的感受是:不能用 Node 生态里惯用的那些依赖原生模块的库,所有和加密相关的操作都要走 Web Crypto API 或者兼容该标准的库。
另一个痛点是请求处理方式的碎片化。裸写 Worker 的 fetch 事件,所有路由判断都要自己拆 URL、自己匹配路径,虽然可行,但一旦接口超过两三个,代码就乱得很快。加上要处理 OPTIONS 预检、统一返回格式、鉴权中间件,手写 if-else 链会非常难受。所以第一步就是引入框架,Hono 几乎是这个场景下的首选。
1.2 Hono 与 Cloudflare Workers 的天然契合
Hono 的好处是它从设计上就是按 Web 标准来的,运行时不绑定 Node。它内部依赖的 Request/Response 都是标准 Web API,这在 Workers 上完全一致,这也是它比 Express 这类传统框架更适合边缘环境的原因。Express 的中间件模型虽然也被 Hono 借鉴了,但 Hono 的体积非常小,路由注册和匹配性能也好,对 Worker 冷启动的影响可以忽略。
另外 Hono 支持类型化的环境变量和变量注入。你可以通过泛型定义Bindings指定环境变量,通过Variables指定中间件注入到请求上下文里的信息。这对我这个场景极其关键——认证中间件验完 token 之后,要把 userId、用户名这些信息传给后续接口,Hono 的c.set/c.get机制帮我省掉了手动拼装对象传递的麻烦。
1.3 为什么 JWT 更适合边缘无状态认证
有状态认证(比如服务端 session)需要存储会话数据,Workers 虽然有 KV、D1 这类存储,但每次请求读一次存储,延迟和费用都不划算。JWT 的思路完全不同:token 本身就是载体,服务端验签之后从 token 里直接拿信息,不需要查询数据库。这在边缘节点分布很广的场景下尤其合适,任何节点都能独立验签,没有中心化存储瓶颈。
但代价也很明显:token 一旦签发,在过期之前服务端很难主动让它失效。所以设计时要有清晰的分层策略,比如短期 access token 加长期 refresh token。这个后面在扩展建议里会细说,先记在心里。
2. JWT 认证要真正跑通,先搞懂这几个核心细节
2.1 JWT 的三种构成与签名算法选型
JWT 是三个部分用点号拼接起来的字符串:Header、Payload、Signature。Header 里声明了签名的算法,Payload 里可以放用户 id、角色、过期时间、签发时间等信息。Signature 是保证 token 没有被篡改的关键。
有人把它比作一张盖章的门票,这个类比其实很贴切。门票上写着"张三,普通用户,有效期到晚上 8 点",游客和检票员都能看到这些字——这就是 Payload。但如果你拿笔把"普通用户"改成"VIP",检票员一眼就能发现不对,因为门票上有主办方的盖章防伪——这就是签名。读取 Payload 不需要密钥,任何人都能 base64 解码;但伪造签名没有密钥做不到。
签名算法的选择上,HS256 和 RS256 是两种最常用的。HS256 是同一个对称密钥既用来签名又用来验签,实现简单、速度快,适合内部小工具。RS256 是公私钥对,私钥签发、公钥验签,适合把验签能力开放给多个第三方服务。我在内部项目里直接用 HS256,环境变量里放一个足够长的随机字符串就可以。
需要特别注意的还有几个标准字段:
| 字段 | 含义 | 必要性 |
|---|---|---|
sub | 主题,习惯放用户唯一标识 | 强烈建议 |
exp | 过期时间(Unix 秒) | 强烈建议 |
iat | 签发时间 | 建议 |
iss/aud | 签发方 / 接收方 | 多服务时建议 |
2.2 验签失败高频原因与算法混淆攻击
验签的核心就一句话:用同一个密钥和指定算法把 Header + Payload 重算一遍签名,比对两边是否一致。但这中间有个非常经典的坑——算法混淆攻击。
如果你的验签代码没有限定算法列表,攻击者就能把 Header 里的alg改成none,或者想办法用你自己暴露出来的公钥当 HMAC 密钥来签名。所以用jose的jwtVerify时,要显式传algorithms: ['HS256'],这个参数必须写,不要偷懒省略。
还有一个直接决定成败的细节:exp、iat依赖服务器时间。Workers 运行时的时间是同步校准的,所以线上基本不会因为时间导致验签失败,但本地wrangler dev调试时如果电脑时间不准,就会出现神奇的"token 明明刚签发却提示过期"。遇到这类问题,先对时间,再改代码。
2.3 库的选型:为什么是 jose 而不是 jsonwebtoken
Node 生态里最流行的是jsonwebtoken,很多从传统项目转过来的人第一反应就是装它。但注意,jsonwebtoken依赖 Node 自带的Buffer、crypto模块,Workers 的运行时环境并不是完整 Node,直接引用很可能报错。即使通过打包器做了 polyfill,也会增加体积和复杂度。
jose这个库从底层就用 Web Crypto API 实现签名和验签,天然适配 Workers。体积控制得很好,API 是链式风格,签发音量和 Intent 都清晰。另一个选择是 Hono 官方提供的hono/jwt中间件,封装得更简洁,但如果你想在中间件之外还要做登录签发、自定义错误返回,还是直接用jose更灵活。下面的实操代码也以jose为主线,全程可复制。
3. 实操:从零实现 JWT 登录、校验与信息提取
3.1 初始化项目与依赖安装
我习惯用官方脚手架初始化一个 Workers 项目。命令如下:
npm create cloudflare@latest jwt-demo -- --template=hello-world cd jwt-demo npm install hono jose生成的项目结构里已经有src/index.ts、wrangler.toml和package.json。其中wrangler.toml是 Cloudflare Workers 的配置文件,先放一个本地开发用的密钥占位:
name = "jwt-demo" main = "src/index.ts" compatibility_date = "2024-11-01" [vars] JWT_SECRET = "local-dev-secret-please-change"注意[vars]里写的内容是明文,只适合本地开发。生产环境的密钥要单独用npx wrangler secret put JWT_SECRET设置,代码和配置里都不要出现真实密钥。这是第一道安全底线。
3.2 登录接口:签发 Token
先定义一个带环境变量类型和变量注入类型的应用实例:
import { Hono } from 'hono'; import { jwtVerify, SignJWT } from 'jose'; type Bindings = { JWT_SECRET: string; }; type Variables = { userId: string; username: string; }; const app = new Hono<{ Bindings: Bindings; Variables: Variables }>(); function getSecret(secret: string): Uint8Array { return new TextEncoder().encode(secret); }jose内部用的是 Web Crypto API,需要把字符串密钥转成Uint8Array。所以单独封装一个getSecret很实用。
登录接口本身很简单,校验用户名密码之后签发 token:
app.post('/api/login', async (c) => { const { username, password } = await c.req.json(); if (username !== 'demo' || password !== '123456') { return c.json({ error: '用户名或密码错误' }, 401); } const token = await new SignJWT({ username, role: 'admin' }) .setProtectedHeader({ alg: 'HS256' }) .setSubject('user_001') .setIssuedAt() .setExpirationTime('2h') .sign(getSecret(c.env.JWT_SECRET)); return c.json({ token }); });签发的过程中我们顺手做了一件事:setSubject('user_001')把用户唯一标识放进了sub字段,自定义的username和role也放进了 Payload。等一下验证的时候都能取到。
setIssuedAt()会自动打上当前时间戳,setExpirationTime('2h')则让 token 两小时后过期。过期时间我习惯用字符串描述,可读性比写秒数好。
3.3 认证中间件:拦截请求并验签
签发 token 只是第一步。第二步,也是核心,是让所有受保护的接口在进入业务逻辑之前先验签。Hono 的中间件正好干这事:
app.use('/api/*', async (c, next) => { const auth = c.req.header('Authorization'); if (!auth || !auth.startsWith('Bearer ')) { return c.json({ error: '缺少认证信息' }, 401); } const token = auth.slice(7); try { const { payload } = await jwtVerify( token, getSecret(c.env.JWT_SECRET), { algorithms: ['HS256'] } ); c.set('userId', 'user_001'); c.set('username', payload.username as string); await next(); } catch (err) { return c.json({ error: 'token无效或已过期' }, 401); } });几个关键点拿出来说:
auth.slice(7)是去掉"Bearer "前缀,只留 token 本体。用startsWith('Bearer ')先判断格式,避免乱传非 Bearer 字符串。jwtVerify第三个参数里的algorithms: ['HS256']非常重要,忘了写等于把算法混淆攻击的大门打开了。
在c.set里把解析出来的用户信息放进上下文,后续的所有接口通过c.get就能拿到。这一步是 Hono 让人舒服的地方,信息流很清晰。
3.4 从 Token 中拿回用户信息:两种路径
第一种路径,也是最推荐的——直接在中间件里解析,注入到上下文。上面的代码已经体现。后续接口直接读:
app.get('/api/profile', (c) => { return c.json({ userId: c.get('userId'), username: c.get('username'), message: '这是受保护的接口,token 有效才能看到', }); });第二种路径,是在具体接口里手动验签取信息。适合那种不想做全局中间件、只想对个别接口保护的场景。比如:
app.get('/api/user-info', async (c) => { const auth = c.req.header('Authorization'); if (!auth || !auth.startsWith('Bearer ')) { return c.json({ error: '缺少认证信息' }, 401); } try { const { payload } = await jwtVerify( auth.slice(7), getSecret(c.env.JWT_SECRET), { algorithms: ['HS256'] } ); return c.json({ username: payload.username, role: payload.role, sub: payload.sub, }); } catch (err) { return c.json({ error: 'token无效或已过期' }, 401); } });两种路径的使用场景我的经验是:系统里有多个接口都需要登录态,就用中间件全局拦截;只有一两个接口需要验证身份,就用第二种,减少代码冗余。
jwtVerify返回的payload里,自定义字段在 TypeScript 下需要做类型断言,上面的payload.username as string就是干这个的。因为标准JWTPayload只定义了sub、exp、iat这些字段,自定义字段要自己声明。
写到这里,一个最小可用闭环已经完成了。梳理一下整个过程:用户提交账号密码,服务端验证通过后签发 token;后续请求带上Authorization: Bearer <token>,中间件验签并解析用户信息;业务接口直接拿用户身份。就这么简单。
4. 真实部署中最容易踩的坑与排查方法
4.1 密钥存放与生产环境切换
本地开发用wrangler.toml的[vars]写死密钥是图省事,但这个文件通常会跟着项目同步到代码仓库,一旦泄露,所有人都能伪造你的 token。生产环境一定要用npx wrangler secret put JWT_SECRET单独设置,这样密钥会以密文形式存在平台的安全存储中。代码里读取方式不变,还是c.env.JWT_SECRET,但实际值在不同环境各不相同。
另外一个常被忽略的点:如果计划在多环境(preview、production)下部署,每个环境的 secret 要分开设置。因为一套环境里签发验证用的是自己的密钥,不会跑到别的环境去互认。
我在实际项目里做的是:本地用随机生成的长字符串,比如openssl rand -hex 32的输出;线上用 wrangler secret 单独设置;测试环境单独生成另一套。这套流程不复杂,但让人安心。
4.2 过期时间与本地调试时间问题
最典型的本地调试问题:刚签发完 token,拿去请求受保护接口,居然返回token无效或已过期。第一反应可能是代码写错了,但其实经常是电脑本地时间不准。JWT 的iat和exp是基于 Unix 时间戳的,本地时间偏移会导致签发时间和验签时间对不上。
解决办法很简单,先把系统时间同步准确再试。线上如果出现大面积验签失败,先检查服务器时间、再看算法配置,最后才去怀疑密钥不一致。
过期时间的设置也要想清楚。内部工具两小时很合适,对外 API 可能短到 15 分钟更好。太短的 token 用户体验差,太长则风险大。建议先短后调,如果用户频繁要重新登录,再适当延长。
4.3 CORS、缓存与跨域场景
如果 Worker 接口是要给浏览器里的前端页面调用的,CORS 绕不过去。Hono 里可以用hono/cors中间件简单地处理:
import { cors } from 'hono/cors'; app.use('/api/*', cors({ origin: 'https://你的前端域名', allowHeaders: ['Content-Type', 'Authorization'], allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], }));注意allowHeaders里如果少了Authorization,浏览器发请求时会把头拦截掉,后台永远收不到 token,这个问题非常隐蔽。前端控制台看请求明明带了Authorization,但服务端就是收不到,原因就在这里。
缓存这块,token 认证的接口尽量不要启用 Workers 的缓存策略,尤其是返回了用户私有数据的接口。一旦响应被边缘缓存命中,其他用户可能看到别人的信息。最简单的方式是给认证接口设置Cache-Control: no-store,或者在响应头标注私有性:
app.get('/api/profile', (c) => { c.header('Cache-Control', 'no-store'); return c.json({ username: c.get('username') }); });4.4 一个简单的错误排查清单
把这次实践遇到的典型问题整理成一张速查表,方便后来者对照排查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 返回 401 缺少认证信息 | 请求头没带 Authorization | 看请求头,确认Bearer前缀也在 |
| token 无效或已过期 | 本地时间不准 / 密钥不一致 / token 真的过期了 | 先对时间,再用同一密钥重复签发验证 |
| 本地正常,线上全部 401 | 没设置线上 secret | npx wrangler secret put JWT_SECRET完成后重启 worker |
| 前端带 token 但后端收不到 | CORS 的 allowHeaders 少配了 Authorization | 在 cors 中间件中补上 |
| payload 里的自定义字段读不到 | 类型断言没写,或字段名拼错 | 打印 payload 检查实际结构 |
| 浏览器里能解码 token 的内容 | 这是设计如此,Payload 本身不加密 | 不要把密码等敏感信息放 token |
第 7 项值得单独强调。JWT 的 Payload 只是 base64 编码,不是加密,任何人都能在 jwt.io 之类的工具里解码看到内容。所以 token 里不要放密码,不要放手机号、身份证号这类隐私数据。只放服务端需要的身份标识和角色,最坏的泄露场景下泄露的也只是用户 id 和角色名。
5. 几个值得尝试的进阶方向
基础认证链路跑通之后,可以根据项目需求继续扩展。下面几个方向是这次实践之后我沉淀下来的优先级建议。
第一个是角色权限控制。我们的 token 里已经写了role: 'admin',但目前的中间件只验证了 token 是否有效,没有控制谁能访问哪些接口。追加一套权限判断逻辑很直接:解析 payload 里的 role,再和接口需要的角色对比,不通过就返回 403。可以把权限判断抽成独立的中间件,按路由挂载。
第二个是短期 access token 加长期 refresh token。本次方案是单 token,两小时过期之后用户就得重新登录,这在内部工具里勉强够用,但在用户体验要求更高的场景就不合适了。改进方案是:签发一个短期 access token 用于访问,同时签发一个有效期数天甚至更长的 refresh token 用于换取新的 access token。由于 refresh token 可以单次使用、轮换方案,安全性比单纯拉长 access token 有效期高很多。Workers 上的实现不难:refresh token 的接口校验长期 token 后,再签发一个新的短期 access token 返回。
第三个是密钥轮换。定期更换JWT_SECRET能降低密钥泄露影响,但如果直接换,所有已签发的 token 都会失效。业界常见的做法是维护密钥列表:新签发的 token 用最新密钥,验签时从列表里按kid(Key ID)找到对应密钥。jose库原生支持setKeyId和传入密钥集合,具体做法可以看官方文档,这里不展开。实际项目中如果只做内部工具,半年轮换一次即可,没必要过度设计。
最后再分享一点个人感受
这次做完之后,最大的体会是:JWT 认证方案本身不复杂,真正决定项目稳定性的是那些容易被忽略的边角——密钥怎么存、算法怎么限定、时间怎么校验、CORS 怎么配。任何一个细节没处理好,线上就是一片 401。按照文章里的顺序一步步搭,先本地跑通,再部署到线上,遇到问题对照排查清单逐条看,基本半天内可以搞定。如果后续遇到更特殊的场景,比如多个 Worker 之间共享同一套认证体系,优先考虑引入 JWKS 公钥验证机制,那又是一种完全不同的解法了。