错误边界、健康检查和优雅退出解决的是“服务失败时怎样把影响限制住”。可一个服务即使从不崩溃、每次都快速返回,也可能稳定地做错另一件事:把不该给的数据交给错误的人。可靠性保护可用性,安全性保护的则是身份、权限和信任边界。
我们是在所有监控都保持绿色的时候发现这条边界缺失的。
那天晚上,报警不是从监控系统来的,而是一条很克制的用户反馈:“为什么我把任务地址里的 id 改了一下,会看到另一条任务?”
接口没有报错,延迟曲线很平,JWT 验签也全部成功。恰恰因为一切看上去都“正常”,这句话让我后背发凉:服务可能正在稳定、快速地把不该给的数据交给已登录用户。
我先做了三件事:暂停相关接口,把访问日志从常规留存切到安全事件保护,再用两个测试账号复现。账号 A 登录后,请求自己的任务返回 200;把 URL 中的任务 id 换成账号 B 的,仍然返回 200。响应里的 ownerId 明明属于 B。
最初的几个猜测都错了。我怀疑缓存键漏了用户 id,关掉缓存后问题还在;我怀疑负载均衡把旧版本流量送了进来,逐个实例核对版本也一致;我甚至检查了 JWT 是否能被篡改,但签名、签发者、受众和过期时间全部通过。转折发生在我把请求链拆成两个问题时:
路由只执行了 findById(req.params.id)。认证中间件证明了“有人合法登录”,业务查询却把“已经登录”误当成“有权访问任何对象”。这不是 JWT 被绕过,而是授权判断根本不存在。
继续排查后,问题不止一个:访问日志里曾记录完整的 Authorization 头;访问令牌有效期过长;更新资料接口把整个 req.body 交给数据层;登出只清了浏览器 Cookie,却没有说明已经签发的访问令牌何时失效。一个看似简单的越权接口,把认证、授权、输入边界、令牌生命周期和审计设计全串在了一起。
这次复盘让我形成了一个很实用的判断:安全不是“有没有登录功能”,而是每份外部数据、每个身份和每项能力,在穿过信任边界时都必须重新接受约束。

事故处理中最诱人的动作,是立刻装一个安全库或在所有路由前补上 verifyToken()。我没有这么做,因为出问题的接口本来就在验签。我要先知道:攻击者能从哪里输入,数据会穿过哪些组件,哪一层相信了不该相信的东西。
我把系统画成客户端、Node.js API、数据库与缓存、第三方服务四块。箭头每跨过一次边界,数据都恢复为“不可信”:浏览器传来的 userId 不可信;JWT 中的 sub 只有在算法、签名、签发者、受众和时效都通过验证后才可用;数据库里的富文本也不能直接塞进 HTML,因为恶意内容可能早在几个月前就被写入。
接着列资产。一个普通任务 API 也至少有五类东西值得保护:
把资产写在纸上后,威胁突然具体起来:匿名请求会枚举账号、猜密码和投递畸形输入;已登录用户会替换资源编号;拿到旧令牌的人会重放请求;伪造的 Webhook 会借服务端身份做事;大量慢哈希还可能耗尽资源。
不可信输入也远不止 req.body。路径参数、查询字符串、请求头、Cookie、上传文件、Webhook、消息队列,以及其他服务返回的数据,都需要在自己的入口重新验证。我用下面这张表给每种风险指定第一责任防线:
威胁模型不必写成厚文档。只要能说清资产、入口、信任边界、攻击者能力和失败后果,技术选择就有了依据。系统每增加一个外部服务、上传入口或高权限操作,我都会更新这张图。
越权修掉后,我把视线移到用户表。密码验证只需要回答“这次输入是否与设置时相同”,服务没有取回密码内容的业务需求,所以密码不该明文保存,不该做可逆加密,也不能只跑一遍速度很快的 MD5 或 SHA-256。正确工具是专门为密码设计的单向慢哈希。

盐是每个密码独有的随机值。即使两位用户使用同一个密码,不同的盐也会产生不同摘要。攻击者拿到数据库后,不能用同一张预计算表批量命中,只能逐条尝试。
盐不需要保密。成熟密码库会生成盐,并把算法、版本、参数、盐和摘要编码在同一个字符串里。应用应把这个完整字符串原样保存,不要自己设计“先拼接盐,再哈希”的格式。
成本参数决定一次计算消耗多少 CPU、内存或迭代次数。合法用户登录通常只算一次,离线猜测一百万次就要付出一百万次成本。但参数不是越高越安全:单次验证过重时,攻击者也能用并发登录拖垮服务。我会在接近生产规格的机器上压测,为登录峰值和其他请求留出容量,再随硬件变化逐步上调。
新项目可优先选择 Argon2id,受环境限制时考虑 scrypt。bcrypt 仍然常见,更适合在已有系统中继续维护。使用 bcrypt 时,我至少检查三件事:
hash 与 verify/compare,不要自行生成盐或用普通字符串比较摘要。string.length。如果系统使用 pepper,它应当是独立于数据库保存的服务端秘密,例如放在密钥管理服务中。pepper 只是纵深防御,不能替代随机盐;轮换它通常需要用户再次输入密码才能生成新摘要,因此迁移方案必须先于上线设计。
密码进入系统时,我会先检查合理长度和已泄露密码策略,再交给密码哈希库。修改密码时只对新的密码内容做哈希,避免把已编码的摘要再次哈希。登录时先按规范化账号查用户,再由库比较输入与摘要。
“账号不存在”和“密码错误”对外应返回同一种消息,例如“账号或密码错误”。否则响应文案、状态码乃至明显的耗时差异都可能帮助攻击者枚举账号。对内日志可以区分原因,但不能记录密码内容、摘要、完整 Cookie 或令牌。
密码哈希慢是设计目标,也会消耗真实资源。登录限流既防撞库,也保护线程池、CPU 和内存;它必须与哈希参数一起压测。
事故里的 JWT 没坏。它正确回答了“你是谁”,却没有回答“这个身份能否对这个对象执行这个动作”。认证和授权必须是两道独立的门:第一道识别主体,第二道把主体、动作、对象和环境放在一起判断。

WWW-Authenticate: Bearer,提示客户端需要认证。例如 user-17 对 task-9 发起 update,服务必须确认任务归该用户所有,或者当前用户拥有明确允许的管理权限。只检查 role === 'user',完全没有回答资源归属问题。
最初出问题的逻辑可以缩成下面这行。它虽然位于认证中间件之后,却仍然存在水平越权:
// 危险:只按客户端给出的 id 查任务
const task = await tasks.findById(req.params.id);查询条件应该把当前身份带进去:
// 更稳妥:在查询阶段就限定资源归属
const task = await tasks.findOne({
id: req.params.id,
ownerId: req.auth.userId,
});把主体写进查询条件,让“读取对象”和“确认归属”成为同一个原子意图,比先查对象、稍后再判断更不容易漏。管理员等特殊分支仍需显式表达;授权的默认值应当是拒绝,而不是“只要登录就允许”。更新和删除同理,而且数据写入层最好再做一次租户或归属约束,避免路由层的一次疏忽直接穿透。
JWT 中即使带了 roles 或 scope,它们也只是签发时刻的一张快照。用户刚被降权,旧令牌里的角色可能还在有效期内。高风险操作应读取当前权限状态、使用短时效令牌,或通过会话版本、令牌内省等机制让权限变化更快生效。
排查日志时,我发现曾有调试代码记录完整的 Authorization 头。那一刻,越权漏洞不再只是“改 id”:只要日志读取权限过宽,Bearer Token 就可能变成第二条冒用路径。Bearer 的含义很直接——谁拿到,谁就能在有效期内使用。
Session 与 JWT 都能承载登录状态。选择时我不再争论谁“先进”,而是比较撤销速度、密钥扩散、浏览器存储和服务边界。

Session 登录后生成高熵随机编号,浏览器 Cookie 只保存编号,用户、角色、超时与撤销状态留在服务端。它的优势很朴素:退出登录、封禁账号或撤销某台设备时,删除相应记录即可立即生效。
代价是每个请求通常要访问会话存储。多实例部署时不能依赖单进程内存,需要 Redis 一类共享存储,并设计过期、并发与故障策略。对于同一个 Web 应用和后端,Session 往往比自建一套复杂 JWT 刷新协议更容易守住边界。
常见的签名 JWT 由 Header、Payload 和 Signature 组成。Header 与 Payload 只是 Base64URL 编码,拿到令牌的人可以直接读取;签名用于发现篡改,并证明令牌来自持有签名密钥的一方。因此,密码、密钥、证件号码等秘密不能放进 Payload。
验证 JWT 时不能止于“库没有抛错”,还应固定并检查:
alg。iss,确认由预期签发者发出。aud,确认这个令牌本来就是给当前 API 使用的。exp 与可选的 nbf,确认当前处于有效时间窗。sub,确认主体格式正确,并在需要时确认业务用户仍然有效。jti 或会话标识,在需要防重放、撤销或审计时建立关联。typ 与不同令牌各自的验证规则,避免把刷新令牌、ID Token 和访问令牌混着验。使用 HMAC 时,每个能验签的服务同时拥有签发能力,密钥泄露的影响面较大。跨服务场景通常更适合非对称签名:认证服务保管私钥,资源服务只持有公钥。kid 可以协助轮换,但必须从受控映射或可信密钥集合中选钥,不能根据攻击者提供的地址下载密钥。
JWT 可以让资源服务只验签而不查会话库,但一旦产品要求“立刻踢下线”“只注销一台设备”“密码修改后全部失效”,就需要撤销列表、会话版本或短访问令牌加刷新会话。系统重新引入状态并不失败,它只是说明撤销本来就是一个状态问题。
浏览器 JavaScript 可以读取 localStorage 与 sessionStorage。一处 XSS 就可能带走其中长期保存的令牌,所以不应把高价值认证令牌长期放在那里。一种常见 Web 方案是:
Authorization: Bearer ... 发送。HttpOnly、Secure 和合适 SameSite 的 Cookie,并缩小 Path 与 Domain 范围。SameSite 是纵深防御,不是所有系统里都能单独承担 CSRF 防护。原生 App 应使用操作系统提供的安全存储。任何 Bearer Token 都不应放进 URL 查询参数,因为浏览器历史、代理、分析系统与访问日志都可能记录 URL。传输过程必须使用 TLS。
原实现只关心签发,好像登录成功就是故事结尾。事故后我把签发、使用、过期、刷新、轮换、撤销和审计画成一条时间线:令牌价值多高、能活多久、被复制后怎样发现、用户退出时究竟让什么失效,都必须有明确答案。

访问令牌只用于访问 API,权限范围尽量小,有效期尽量短。具体是 5 分钟、15 分钟还是更长,由业务风险、交互体验与撤销能力共同决定,不能机械照搬一个固定数字。
刷新令牌只发给认证端点,用来换取新的访问令牌。它寿命更长、价值更高,服务端应保存其摘要与会话元数据,而不是把原始值写进数据库。因为刷新令牌本身是高熵随机数,这里的摘要用于避免数据库泄露后直接重放,与密码需要慢哈希的原因不同。
每次刷新都执行轮换:以原子操作把旧刷新令牌标记为已用,再签发新令牌。如果同一个旧令牌再次出现,可能是客户端并发重试,也可能是令牌已被复制。系统应按风险策略撤销整个令牌家族并要求重新登录,同时保留可调查的安全事件;数据库中的“标记旧令牌 + 创建新令牌”必须置于事务或等价的并发控制中。
如果资源服务完全离线验 JWT,退出时撤销刷新令牌并不能让已经签发的访问令牌瞬间失效,它仍可能使用到 exp。这就是短访问有效期的意义。
业务若要求立即失效,可以在每次请求检查 sid/jti 撤销状态,或校验用户的会话版本。这会增加共享状态读取,还要处理缓存一致性。另一种方案是只对付款、改密等高风险操作查实时状态,让普通读取接受几分钟的失效窗口。选择哪一种,取决于能接受的事故半径。
密码修改、账号冻结、检测到刷新令牌重放或设备丢失,都应该触发相应范围的撤销。密钥轮换是另一层机制:它解决签名密钥生命周期,不应拿“更换全局密钥、让所有人一起掉线”代替正常的会话撤销。
旧代码把 req.body 整体传给数据层。开发者本意是省去逐字段赋值,可一旦客户端提交 role、ownerId 或内部状态字段,这份“方便”就可能变成批量赋值越权。客户端界面没有这些输入框,不等于攻击者不能构造这些字段。
输入校验的目标,是让进入业务流程的数据形状和含义都明确。它有两层:语法校验确认类型、格式、长度与枚举;语义校验确认业务关系,例如结束时间晚于开始时间、库存不能为负、任务确实属于当前用户。
客户端校验只改善体验,攻击者可以绕过页面直接调用 API。服务端必须在边界重新校验,而且默认拒绝 Schema 未声明的字段。否则下面这段代码可能让普通用户修改本不该出现的属性:
// 危险:role、ownerId 等字段也可能被一并写入
await users.update(req.auth.userId, req.body);我会构造明确的 DTO,只把允许的字段交给数据层:
const update = profileSchema.strict().parse(req.body);
await users.update(req.auth.userId, update);校验不能替代正确的数据库或系统调用。SQL 使用参数化查询:
const result = await db.query(
'SELECT id, email FROM users WHERE email = $1',
[email],
);调用系统程序时优先使用不经过 Shell 的 API,例如 execFile 或 spawn 并传入参数数组;能不用外部命令时就不调用。绝不能把用户输入拼进 child_process.exec()。正则表达式、JSON Body 和上传文件也要限制大小与处理时长,避免 ReDoS、超大请求或解压炸弹耗尽资源。
我曾希望一个中间件能替系统兜住注入、XSS、CSRF、越权和暴力尝试,后来发现这只是另一种错误猜测。每道防线只消除一部分攻击条件,组合起来才会压缩攻击路径。

SQL 注入、NoSQL 操作符注入和命令注入虽然载体不同,根因相似:外部数据改变了原本的指令结构。输入白名单能缩小攻击面,但核心控制仍是参数化查询、受限的查询构造器和不经过 Shell 的进程 API。
对 MongoDB 一类文档数据库也不能直接把整个 req.body 当查询对象;攻击者可能提交带操作符的嵌套对象。Schema 应明确字段必须是字符串还是对象,数据访问层再用已验证的标量构造查询。
纯 JSON API 不会自己执行脚本,但它可能保存评论、昵称或富文本,后来由管理后台渲染。防护重点是根据最终输出上下文编码:HTML 文本、属性、URL 与 JavaScript 字符串使用的规则并不相同。需要允许富文本时,使用成熟的 HTML 清洗器限定标签和属性。
Content Security Policy 可以限制脚本、对象与页面的加载范围,降低漏洞被利用后的影响,但不能代替正确编码。HttpOnly 能阻止脚本直接读取 Cookie,却不能阻止同源恶意脚本借用户浏览器发起已认证请求,所以修复 XSS 本身仍是第一位。
CSRF 利用的是浏览器会自动携带 Cookie。对修改数据的请求,应结合 CSRF Token、精确的 Origin 校验或 Fetch Metadata 策略;Cookie 同时设置 Secure、HttpOnly 与合适的 SameSite。不要用 GET 修改服务端状态。
Bearer Token 放在 Authorization 头时,普通跨站表单无法自动附加这个头,经典 CSRF 条件会减少。但如果 Token 被长期放在可读存储中,XSS 又能直接偷走它。安全设计是在不同威胁之间做明确取舍,而不是宣布某一种存储方式“解决了所有问题”。
CORS 决定浏览器是否允许某个页面读取跨源响应。脚本、命令行工具和攻击者服务器不受浏览器 CORS 约束,所以 Access-Control-Allow-Origin 不能代替认证和授权。
需要跨域时只允许明确站点;带凭证的请求不能随意反射任意 Origin。预检通过也只表示浏览器允许发送,后端仍要验证身份、权限、CSRF 条件和输入。
只要接口会根据用户输入抓取头像、预览 URL 或转发 Webhook,就要考虑 SSRF。优先使用目的地白名单;解析域名后拒绝环回、私网、链路本地和云元数据地址;跟随重定向时重新校验每一跳;同时在网络层限制服务的出站访问。只用字符串判断 URL 前缀,很容易被编码、DNS 解析与重定向绕过。
Express 可以用 helmet() 建立基线,再根据页面实际资源配置 CSP。常见响应头包括:
Content-Security-Policy:限制脚本、样式、图片、表单目标与页面嵌入范围。Strict-Transport-Security:在 HTTPS 部署中要求浏览器继续使用 HTTPS。确认所有子域都准备好后再扩大范围。X-Content-Type-Options: nosniff:阻止浏览器猜测 MIME 类型。frame-ancestors 或兼容用的 X-Frame-Options:限制页面被嵌入,降低点击劫持风险。Referrer-Policy:减少 URL 信息随 Referer 外泄。Helmet 提供的是起点,CSP 仍需按站点资源调整。对于只返回 JSON 的 API,部分页面型响应头作用有限;TLS、认证、授权、输入校验和正确的 Content-Type 仍必须独立完成。旧的 X-XSS-Protection 不能替代 CSP,现代配置通常会将其关闭。
代码修对只能阻断已知路径。密钥如何注入、登录能否被无限调用、异常能否被及时识别,会决定下一次问题是一次失败请求,还是持续数小时的事故。
生产代码缺少 JWT 密钥时应当启动失败,绝不能回退到 your-secret。开发环境可以从环境变量读取,生产环境则应由密钥管理服务注入,并做到:
环境变量只是把值交给进程的通道,不是完整的密钥管理系统;同机高权限进程、崩溃转储和诊断工具仍可能接触其中内容。
只按 IP 限流会误伤共享出口,也容易被代理池绕过;只按账号限流又可能让攻击者故意锁死某位用户。生产系统通常使用两个独立桶:账号与请求源组合的短窗口、请求源地址的长窗口,再叠加渐进延迟、验证码或 MFA 风险策略。
多实例部署必须使用共享限流存储,并确保计数更新是原子的。反向代理后的客户端 IP 只能来自受信代理写入的头;若 Express 的 trust proxy 配错,攻击者可以伪造转发头绕过限制。触发限制时返回通用的 429,不透露账号是否存在。
我会记录登录成功与失败、限流命中、授权拒绝、刷新令牌重放、账号冻结和密钥轮换等事件,并带请求 id、时间、结果、受控的用户标识与可信请求源信息。密码、完整 Authorization 头、Cookie、刷新令牌、重置链接和密钥必须进入日志拒绝清单。
对外的 401、403 和 500 保持克制;对内日志保留足够上下文,但同样使用字段白名单并脱敏。排障信息与安全事件要能关联,凭证却绝不能因为“方便调试”进入第二个泄露面。
为了验证这些判断能落到代码上,我用 Node.js 20+、Express、Argon2id、jose、Zod、Helmet 和 express-rate-limit 做了一个最小样本。用户、任务和刷新会话暂时放在内存 Map 中,目的是让完整流程可以直接运行;真实部署必须换成带唯一约束和原子事务的数据库、共享会话存储与共享限流器。
在空目录中安装依赖:
npm init -y
npm pkg set type=module
npm install express helmet express-rate-limit cookie-parser zod argon2 jose将下面内容保存为 server.mjs:
import express from 'express';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import argon2 from 'argon2';
import { rateLimit } from 'express-rate-limit';
import { SignJWT, jwtVerify } from 'jose';
import {
createHash,
randomBytes,
randomUUID,
} from 'node:crypto';
启动前生成至少 32 字节随机密钥。本地验证可以这样做:
export JWT_SECRET_BASE64="$(node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))")"
export APP_ORIGIN="http://localhost:3000"
node server.mjs生产环境不能在每次重启时临时生成密钥,否则已有访问令牌会全部失效。生产密钥应由密钥管理系统稳定注入,再按既定窗口轮换。
再用几条请求走通注册、登录和受保护接口:
curl -s http://localhost:3000/auth/register \
-H 'content-type: application/json' \
-d '{"email":"reader@example.com","password":"correct-horse-battery"}'
ACCESS_TOKEN="$(curl -s -c cookies.txt \
http://localhost:3000/auth/login \
-H 'content-type: application/json' \
-d '{"email":"reader@example.com","password":"correct-horse-battery"}' \
| jq -r .accessToken)"
curl -s http://localhost:3000/me \
-H "authorization: Bearer $ACCESS_TOKEN"
我刻意让几个边界在代码中可见:访问 JWT 固定算法并校验 iss、aud、exp、typ、sub 与 sid;刷新令牌只在 HttpOnly Cookie 中出现,服务端只保存摘要;刷新时轮换并检测旧令牌重放;对象接口按当前用户 id 检查归属;错误响应不返回堆栈。
这个样本并不冒充生产方案。内存状态无法跨实例共享,也无法在重启后恢复;登录限流只有单进程、单维度;Origin 白名单只有一个站点;刷新轮换不是原子事务;密码重置、MFA、邮件验证和签名密钥轮换都未实现。退出登录只撤销刷新令牌家族,已经签发的访问令牌仍能使用到十分钟后过期。若业务要求立即失效,认证中间件就要查询撤销状态,并承担共享状态读取与一致性成本。
修复越权查询只是止血。我还撤销了可能暴露的会话、缩短访问令牌有效期、清理不该保留的凭证日志,并为“账号 A 读取账号 B 对象”补了回归测试。最后,我沿着请求从外到内做一次复查:
先确认入口:TLS 是否在可信代理处正确终止,代理头是否只信任自己的负载均衡器,请求体、文件、超时和并发是否有限制。
再确认身份:密码是否使用专门的慢哈希,登录错误是否保持通用,令牌是否固定算法并验证签发者、受众、类型与时效,浏览器凭证是否放在合适位置。
接着确认权限:受保护路由是否默认拒绝,是否在每次请求检查主体、动作和对象归属,客户端提交的 ownerId 与 role 是否被拒绝或忽略。
然后确认数据:所有外部输入是否经过严格 Schema,数据库访问是否参数化,富文本是否清洗,输出是否按上下文编码,CORS 与 CSRF 是否根据真实客户端设计。
我现在用一句话检验设计是否清楚:任何一个凭证、字段或服务失守时,我们是否知道攻击者因此多了什么能力、这项能力能持续多久,以及哪一层会发现并阻止它继续扩大。
这次事故留给我的方法论,不是一串库名,而是五个连续动作:先画信任边界,再识别身份;把授权写进每一次对象访问;让输入只能成为数据;为凭证设计完整生命周期;最后用限流、最小权限、审计和撤销控制事故半径。
密码哈希会消耗 CPU 和内存,JWT 验签有计算成本,限流与撤销依赖共享状态。安全从来不是免费的,但省掉这些成本,往往只是把账单推迟到事故发生那一刻。真正“守得住”的系统,不是假设每道防线永不失手,而是即使一层失守,攻击者也拿不到无限权限和无限时间。
这些安全边界也把系统带到了容量问题面前:密码哈希占用计算资源,限流和会话依赖共享状态,审计增加 I/O,真实流量还会把每一项固定成本同时放大。功能、稳定性和安全性都成立以后,服务仍要证明自己在高峰下能及时响应,并且扩容不会把连接、缓存和内存开销一起成倍复制。
最后确认运营能力:密钥能否轮换,会话能否按设备或全局撤销,登录和高成本端点是否使用共享限流,日志能否发现异常又不泄露凭证。