做接口开发的人基本都绕不开身份认证这道坎。最近我在折腾一个跑在Cloudflare Workers上的内部工具需要给一组API加上登录校验想了半天最后用了Hono框架配合JWT来实现流程走通之后发现这套组合比想象中顺手而且踩过的坑也很有代表性干脆整理成一篇文章分享出来。写这篇东西的时候我会尽量把原理、代码、以及实际跑的时候遇到的那些问题都讲透你在自己项目里复现的时候能少走点弯路。整套方案的核心就三件事在边缘节点上架起Hono应用用它处理路由在登录接口里签发JWT在受保护的接口前面挂一个中间件把Token里携带的用户信息解析出来塞进请求上下文。这个模式在传统Node服务里很常见但放到Cloudflare Workers这个环境里有几个细节会不一样比如运行时的API限制、包的选择、密钥的管理方式这些恰恰是最容易翻车的地方。1. 为什么选Cloudflare Workers加Hono来做JWT认证先说选型。如果你只是要做一个轻量API服务认证逻辑又不复杂其实用什么框架差别不大。但一旦你想把它部署到边缘节点上让请求在离用户最近的地方完成校验那选择范围就窄了很多。Workers的运行环境不是完整Node.js它跑的是V8引擎加一部分Web标准API所以像Node里那些依赖crypto模块的库直接用经常会报错。Hono这个框架我很早就注意到了它最大的特点就是轻整个框架本身就是为边缘运行时设计的可以用标准的Request和Response对象在Workers、Deno、Bun这些环境里都能跑。配合官方的hono/jwt中间件或者直接用底层的jose库写JWT认证非常自然不用像在Express里那样去手动兼容Node和Worker两套环境。这一次我实际用的组合是Cloudflare Workers作为部署运行环境Hono作为Web框架jose库负责JWT的签名与验签wrangler做本地开发和线上发布选jose而不是自己手写签名逻辑是因为JWT虽然看起来就是三段字符串拼起来但签名和校验的细节太多标准库更新也频繁没必要把时间浪费在这些底層实现上。jose是纯Web API实现在Workers里兼容性很好Cloudflare官方文档里也经常拿它举例。2. 先理清楚JWT到底是怎么工作的以及它和Cookie Session的区别JWT是JSON Web Token的缩写说直白一点就是服务端给客户端发一张带签名的“身份卡”。这张卡由Header、Payload、Signature三部分组成每部分都做Base64Url编码然后用点号拼起来。Header声明了签名算法Payload放业务数据Signature则是对前两部分的签名。三个部分分别长这样// Header { alg: HS256, typ: JWT } // Payload { sub: user_123456, name: 演示用户, role: admin, iat: 1710000000, exp: 1710086400 } // Signature HMACSHA256( base64url(Header) . base64url(Payload), secret )整个Token看起来就是eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMzQ1NiIsIm5hbWUiOiLnp43npL7nlKh_1.签名部分关键的“从token中拿到认证信息”其实就是把中间那一段Payload取出来Base64Url解码成JSON然后读取里面的用户ID、角色这些字段。但这里有个前提你必须先验证签名是合法的否则任何人都能自己伪造一段Payload塞给你你的接口就会被任意伪造身份的人直接调用。所以先验签后取数据这个顺序绝对不能反。那为什么不直接用Cookie Session呢因为Session方案需要在服务端存一份会话数据要么放内存要么放到Redis或者Cloudflare KV。在Workers这种边缘计算场景里每个请求可能被路由到不同的节点内存Session基本没法用引入KV又增加了架构复杂度。JWT是无状态的服务端只需要持有密钥不需要存储任何会话记录请求来了验一下签名就完事。对于API服务来说非常合适。当然JWT也有它的短板。最大的问题是Token一旦签发在有效期内没法主动作废。如果用户改了密码或者账号被冻结已经签出去的Token在exp之前仍然能用。这在权限要求非常严格的后台系统里可能是个痛点解决办法要么把Token有效期设短一点要么引入刷新机制要么干脆在关键操作里再加一层权限校验。3. Workers环境和本地开发环境的搭建在写认证逻辑之前先把基础环境跑通。我用的是wrangler作为命令行工具项目结构很简单my-worker/ ├── src/ │ ├── index.ts // Hono入口 │ ├── auth.ts // 认证相关逻辑 │ └── middleware.ts // JWT校验中间件 ├── wrangler.toml // Workers配置文件 ├── package.json └── tsconfig.json初始化项目用下面两条命令npm create cloudflarelatest my-worker -- --typetypescript cd my-worker npm install hono jose这里有个细节值得说一下jose和hono/jwt的关系。Hono从某个版本开始内置了jwt中间件直接import { jwt } from hono/jwt就能用。但实际经验告诉我在生产项目里最好还是直接依赖jose自己写一个适配中间件。原因后面第四章我会详细讲简单的说就是Hono内置中间件的返回结构在不同版本里有变化而且它能做的事情比较死板不如手写灵活。wrangler.toml里我会先占一个密钥的坑位本地开发时可以直接写在[vars]里线上则用wrangler secret put单独设置name my-worker main src/index.ts compatibility_date 2024-09-01 [vars] JWT_SECRET dev-secret-local-only注意这个JWT_SECRET本地开发可以放配置文件里图方便但线上部署绝对不能这么干Cloudflare提供了Secrets机制就是专门给这类敏感配置用的。线上操作命令wrangler secret put JWT_SECRET这条命令会提示你输入密钥内容输入完之后会自动加密存储。之后Worker代码里再引用env.JWT_SECRET就能读到真实值而wrangler.toml里写的那一版会被覆盖掉。4. 核心代码实现登录签发Token、中间件校验、解析认证信息这一步是整个项目的重头戏。我按照实际开发的顺序来走先做登录接口签发Token再做校验中间件把认证信息塞进请求上下文最后在受保护的路由里读取。4.1 签发Token写一个登录接口由于这是一个演示性质的项目我就用最简单的用户校验方式假设请求体里传了用户名和密码我先和一个写死的字典比对通过之后签发Token。企业项目里这一步会换成查数据库或者调一个用户服务但签发环节的代码是一样的。// src/auth.ts import { SignJWT } from jose; const secret new TextEncoder().encode(env.JWT_SECRET); export async function generateToken(user: { id: string; name: string; role: string; }) { return await new SignJWT({ name: user.name, role: user.role, }) .setProtectedHeader({ alg: HS256, typ: JWT }) .setSubject(user.id) .setIssuedAt() .setExpirationTime(2h) .sign(secret); }这段代码里有几个点要解释一下。第一secret为什么要用TextEncoder转成Uint8Array因为jose的API要求签名密钥要么是Uint8Array要么是KeyLike类型。直接把字符串传进去会报TypeError这是很多人第一次用jose会踩的坑。第二setProtectedHeader里的alg要和签名算法匹配这里用的HS256就是对称签名签发和校验用的是同一个Secret。第三setExpirationTime(2h)这里的过期时间单位是相对时间jose内部会基于iat自动计算出exp最终Payload里存的是Unix时间戳秒。很多人在写JWT的时候容易把时间弄成毫秒但标准里规定exp和iat都是秒。你可以自己打印Token到jwt.io上去验证如果时间不对校验那边就会报expired。登录接口的长这样// src/index.ts import { Hono } from hono; import { generateToken } from ./auth; type Bindings { JWT_SECRET: string; }; const app new Hono{ Bindings: Bindings }(); app.post(/api/auth/login, async (c) { const { username, password } await c.req.json(); // 演示用的写死用户实际项目查数据库 if (username admin password admin123) { const token await generateToken( { id: user_123456, name: 演示用户, role: admin }, c.env.JWT_SECRET ); return c.json({ token, tokenType: Bearer, expiresIn: 2h }); } return c.json({ message: 用户名或密码错误 }, 401); });这个接口的逻辑不复杂接收JSON比对用户名密码通过之后就返回Token。4.2 手写一个JWT校验中间件Hono的中间件本质就是一个函数接收Context和Next处理完业务后调用await next()往下走。我写了一个authMiddleware任务有三个从请求头里拿Token、用jwtVerify验签、把Payload里的用户信息挂到c.set(user)上。// src/middleware.ts import { jwtVerify } from jose; import type { MiddlewareHandler } from hono; type JwtPayload { sub: string; name: string; role: string; iat: number; exp: number; }; export const authMiddleware: MiddlewareHandler async (c, next) { try { const header c.req.header(Authorization); if (!header || !header.startsWith(Bearer )) { return c.json({ code: 401, message: 缺少认证Token }, 401); } const token header.slice(7); // 去掉 Bearer const secret new TextEncoder().encode(c.env.JWT_SECRET); const { payload } await jwtVerify(token, secret, { algorithms: [HS256], }); // 说明验签成功把用户信息放到上下文里 c.set(user, { userId: payload.sub, name: payload.name, role: payload.role, }); await next(); } catch (error) { // 这里能捕获到的错误包括Token过期、签名不匹配、格式不对 return c.json({ code: 401, message: Token无效或已过期 }, 401); } };使用方式很简单在需要保护的接口上挂这个中间件// src/index.ts import { authMiddleware } from ./middleware; const app new Hono{ Bindings: Bindings }(); app.post(/api/auth/login, async (c) { /* 上面写过 */ }); // 受保护的路由 app.get(/api/user/profile, authMiddleware, async (c) { const user c.get(user); return c.json({ userId: user.userId, name: user.name, role: user.role, }); });这里有个地方值得展开为什么中间件里自己手写验签逻辑而不是直接用Hono的jwt中间件我刚做的时候其实也是直接用import { jwt } from hono/jwt写起来确实简单三行代码搞定。但我需要的不只是验证Token有没有问题我还要把Token里面的用户信息取出来用。在旧版Hono里jwt中间件会把验证结果挂到c.get(jwtPayload)但不同版本这个字段名不稳定而且Payload的类型需要自己二次断言。在多框架维护的团队里这种隐式的挂载方式很容易造成上下文类型混乱。手写中间件的好处是第一jwtVerify返回的payload是受类型控制的我能明确知道里面有哪些字段第二挂在c.set(user)上后续Handler的c.get(user)能直接拿到类型提示写起来舒服很多第三不依赖框架版本更新带来的行为变化。4.3 从Token中拿到认证信息到底拿的是什么这一节专门回应标题里的后半句。很多刚开始用JWT的同学会有一个错误认知以为Token里面加密了用户信息要用某种“解密”手段才能读出来。实际上JWT的Payload只是做了Base64Url编码并没有加密任何拿到Token的人搜索一下就能看到里面的字段。所以这里所谓的“从token中拿到认证信息”指的是通过验签之后把Payload里携带的声明Claims拿出来使用。实际操作中我会在中间件里把关键信息抽取到一个结构化的对象里而不是直接把原始Payload传给后面的业务代码。原因有这样几个一是干净。下游接口只关心业务需要的字段比如userId、name、role没必要让它看到iat、exp这些技术字段。二是隔离。如果将来改了Token结构比如把sub从用户ID改成别的标识只需要改中间件这一层下游代码完全不受影响。三是安全。Payload里如果有role或者权限相关字段除了验签之外可能还要做二次校验。把抽取逻辑收敛到一个地方以后加逻辑方便。在某些架构里团队甚至会把用户角色、权限点全部放进Token业务代码直接从c.get(user)里取。但我个人的建议是Token里只放必要的、低频变更的信息比如用户ID、姓名、角色。如果一个Token塞了几十K的权限数据先把签出的Token体积搞大网络传输成本上去了其次权限一变还需要重新签发Token很麻烦。真正精细的权限判断应该在业务层查库或者调权限服务完成。5. 踩坑实录几个真正会让你卡半天的细节问题这一节我打算把实际操作中遇到过的坑集中列出来。Internet上关于JWT的教程不少但很多都是复制粘贴出来的不实际跑一遍根本发现不了问题。以下这些是我这次折腾过程中实际踩过的给你的排查过程节省一点时间。5.1 Token过期了但报错信息是“Token无效”这是我第一次跑通流程时最容易困惑的场景。假设用户拿着过期Token请求接口jwtVerify抛出的错误是JWTExpired类型是Error的子类但如果你在外层只是笼统地catch(error)然后返回401前端根本分不清Token到底是过期了还是被篡改了。调试阶段我建议把错误类型打出来看catch (error) { if (error?.code ERR_JWT_EXPIRED) { return c.json({ code: 401, message: Token已过期 }, 401); } if (error?.code ERR_JWS_SIGNATURE_MISMATCH) { return c.json({ code: 401, message: 签名无效 }, 401); } return c.json({ code: 401, message: Token无效 }, 401); }jose的每个业务错误都带一个code字段用这个字段区分原因排查问题会快很多。前端也能根据不同的提示做不同处理比如过期就跳登录页签名无效就提示数据异常。5.2 密钥类型不匹配HS256对应的Secret容易写错前面提到jose的签名密钥需要Uint8Array。还有另一个细节jwtVerify在指定algorithms: [HS256]的同时如果密钥类型不匹配也会报错。我第一次实现时犯过这样一个错本地用new TextEncoder().encode(env.JWT_SECRET)编码的字符串作为SecretToken签发出来没问题但到了生产环境因为线上Secret是通过wrangler secret put设置的内容前面不小心多了一个空格结果所有请求都验签失败。别笑这种问题在真实环境里真的很常见。排查方法是在代码里临时加一句日志打出一段Hash对比一下两边密钥是否一致。5.3 Hono的c.env在本地和线上行为不一样Hono的Context.env字段在Worker运行时里会自动注入环境变量但如果你用的是app.get(/path, authMiddleware, handler)这种写法authMiddleware里拿c.env没问题。可一旦中间件逻辑被抽成一个独立的函数不在Hono的请求链路里那就拿不到env了。我之前踩过的一个坑是把签发Token的工具函数从auth.ts导出然后在登录接口里调用。函数签名写的是(user, env)但是调用的时候忘了把c.env传进去结果代码里env是undefinedTextEncoder直接编码失败。这种问题排查起来要花不少时间因为这个错误提示不是很明确会一直导向“库有问题”的方向。建议是凡是需要用到环境变量的函数要么显式传入env参数要么用Hono的Context上下文绑定不要依赖全局变量。5.4Authorization头和Bearer前缀的大小写HTTP Header的字段名是大小写不敏感的但Bearer前缀的大小写是敏感的。标准写法是Bearer xxxxxB是大写。如果客户端传了bearer xxxxx你直接用header.slice(7)会得到一个错误的结果因为它比标准多了6个字符。更稳的做法是不用slice而是用正则或者split// 用 split 更健壮 const parts header.split( ); if (parts.length ! 2 || parts[0] ! Bearer) { return c.json({ code: 401, message: 认证格式错误 }, 401); } const token parts[1];这个小改动看着不起眼但能省掉很多来自客户端大小写不规范的问题。5.5wrangler dev热更新和jose的兼容性如果你在用wrangler dev做本地调试改了代码之后有时候会遇到模块解析错误尤其是在配合jose库的时候。这个不是代码逻辑的问题而是Workers本地环境的模块缓存没有完全刷新。我遇到过的现象是第一次启动正常改完auth.ts之后保存控制台报Could not resolve jose重启wrangler dev又恢复正常。解决方案很简单遇到这种诡异问题先重启wrangler dev不要花太多时间在代码里找原因。如果你用的是npm版本较新的项目可以顺便升级一下wrangler到最新版新版的模块解析逻辑会好很多。6. 对比一下HS256、RS256和ES256到底该用哪个在做JWT签名算法选型的时候我看到很多人直接无脑用HS256但实际在不同场景下选择应该不一样。我这次用的是HS256因为它最简单适合个人项目和内部工具但如果你做的是面向大量用户的公共服务建议直接上非对称算法。三者的核心区别用一张表格就能说清楚算法签名密钥验证密钥优点缺点HS256同一个Secret同一个Secret实现简单计算快无法公开验证密钥分发麻烦RS256私钥公钥公钥可分发给多个服务验证计算较慢Key长度大ES256私钥公钥签名短计算快安全性高实现稍复杂需要密钥管理在Workers环境里如果你有多个服务需要验证同一份Token用RS256或者ES256会很方便签发方持有私钥其他服务只要配置了公钥就能验签不用把同一个Secret分享来分享去。这在微服务架构里非常常见。但是非对称算法也有代价首先是密钥管理复杂了很多要生成密钥对、定期轮换私钥、把公钥安全地下发到各个服务其次jose里使用私钥做签名需要用到importJWK或者importPKCS8代码会复杂一个等级。个人经验供参考如果你只是在Workers上加一个简单的API认证用户量不大HS256完全够用如果你有多个后端服务需要验证同一个JWT用ES256更合适签出来的Token短计算还快如果你们公司已经有一套基于RS256的登录体系那就保持RS256别乱换。我在这个项目里最终选了HS256因为整个服务只有Worker一个入口不存在密钥分发的需求。如果以后要把认证开放给更多子服务我会迁移到ES256。7. 生产环境的JWT认证还应该注意什么把能跑通的Demo变成能上生产的系统中间还差不少东西。我的建议是至少把下面这几件事做了。7.1 密钥轮换机制JWT用的是对称密钥意味着持有Secret的任何人都能签发合法Token。一旦Secret泄露攻击者可以直接伪造管理员Token。所以在生产环境你需要有密钥轮换的预案。一种常见的做法是支持多密钥验证在中间件里维护一个密钥列表新密钥负责签发旧密钥在缓冲期内依然可以验证。等到旧密钥对应的Token都过期了再彻底移除旧密钥。用jose实现多密钥验证其实很简单jwtVerify的第二个参数可以传一个Uint8Array数组它会按顺序尝试直到有一个成功const secrets [newSecret, oldSecret].map((s) new TextEncoder().encode(s)); const { payload } await jwtVerify(token, secrets, { algorithms: [HS256], });这样即使换了密钥已经签发的Token在有效期内依然能正常使用不会出现用户被迫重新登录的情况。7.2 Token有效期设计Token有效期设多久取决于你的业务场景。内部工具我一般设2小时客户端应用通常7天涉及支付的系统会更短。但小技巧在于短的AccessToken配合refresh_token刷新机制是兼顾安全和体验的好方案。RefreshToken的有效期可以设7天到30天存储在HttpOnly的Cookie里AccessToken有效期只有十几分钟每次请求都带。这样即使AccessToken被截获了攻击者也只有很短的窗口期可以使用。我这次做的内部工具考虑到复杂性没有做刷新机制但还是把AccessToken的有效期设成了2小时2小时之后需要重新登录。这个折中对内部工具来说够用了。7.3 HTTPS是底线别再做明文传输了抛开技术选型不谈不管你的Token签发得多安全如果客户端和服务端之间走的是明文HTTP这些Token在传输过程中可以被任何人截获。Cloudflare Workers默认都会提供HTTPS这一层已经由平台保障了。但如果你在本地调试或者有自己的域名配置一定要确认线上ACME证书配置正常别让HTTP到HTTPS的重定向出了问题。7.4 限流和防暴力破解接口暴露在公网难免会遇到有人拿脚本不断尝试登录。虽然JWT本身不解决这个问题但你在设计登录API的时候最好在前面挂一个限流规则。Cloudflare平台自带WAF和Rate Limiting能力可以在仪表盘里配置也可以直接用Workers Rules。如果没有这些也可以在应用层做简单的IP频率计数存到KV里。我在这个项目里用的是平台自带的Rate Limiting规则只对/api/auth/login设置了一个比较严格的限制同一个IP在一分钟内最多尝试5次登录。实测下来噪音少了很多。8. 一次完整的调用链路演示和日志观察思路把前面的代码串起来之后整个调用链路其实非常清晰我从用户角度梳理一遍方便你对照自己的实现。用户调用POST /api/auth/login传用户名密码登录逻辑校验通过调用generateToken返回JWT用户把JWT放在请求头里:Authorization: Bearer eyJhbGciOi...请求进入受保护路由先过authMiddleware中间件用jwtVerify验签从Payload里提取sub、name、role中间件挂载user对象await next()放行业务Handler读取c.get(user)返回真实数据调试的时候我习惯在authMiddleware里加一行日志看下Payload到底解析出了什么。不过注意日志里不要打印完整Token只打印关键字段避免敏感信息泄漏。用wrangler tail可以实时查看线上日志wrangler tail my-worker它会输出Worker捕获到的所有日志包括中间件里的console.log。这个在排查线上问题的时候非常好用尤其适合验证线上Secret是否和本地一致。9. 实际部署时的一些建议和心得体会最后说一点我在这个项目上折腾完之后的整体感受。Cloudflare Workers上跑Hono配合JWT做认证这套组合真的很适合中小型API服务尤其是那些希望部署简单、没有服务器运维负担的团队。你把认证逻辑写清楚之后整个项目结构非常干净入口路由、认证中间件、业务Handler各司其职没有多余的状态管理。如果要扩展的话后面可以做的方向还有不少。比如把登录记录和Token黑名单放到Cloudflare KV或者D1里实现Token提前作废或者叠加Cloudflare Access在网关层再做一道保护再或者把登录从单用户体系改造成OAuth2授权码模式接入第三方账号体系。这些都是在现有基础上的扩展不需要推翻重来。我个人在实际操作中最大的体会是JWT这个东西原理并不复杂但真正落地的时候细节非常多。密钥管理要小心时间戳别搞错单位算法选型要考虑业务场景Payload只放必要信息保存好你的私钥和Secret。只要这些基本点都踩对了剩下的事情都会很顺。如果你正准备在Workers上做自己的API认证按这篇文章里的思路先把最小链路跑通然后再慢慢补安全和运维层面的东西应该会让你少踩不少坑。