前阵子接了个内部工具的重构需求,问题倒不复杂:登录接口形同虚设,前端跳个登录页就算过了认证,后端接口裸奔了好几个月。我接手时第一件事就是把这套东西推倒重来,把用户认证从登录、密码存储、Token签发到前端联动完整实现了一遍。这个过程中踩了不少坑,也有一些选型上的反复。这篇文章就把完整的实现思路、关键代码和那些“文档里不会写”的注意事项一次讲清楚,适合正在做Web全栈、想把用户认证做得更规范一点的同学参考。
先说个结论:用户认证这件事,难度不在“写接口”,而在“设计边界”。你可以用三天时间把登录注册写出来,但如果不搞清楚Token怎么存、密码怎么存、退出后怎么让它真正失效,那这套系统上线也只是个定时炸弹。下面的内容我按实际开发顺序来展开,从方案选型讲到最后的踩坑记录。
1. 为什么几乎所有项目都绕不开用户认证
1.1 从一次“裸奔”的教训说起
很多开发者早期都干过类似的事:前端登录成功后往 localStorage 里塞一个isLogin = true,后端接口看着没加拦截就以为安全了,结果被同事用一行 curl 把整个用户表拉了下来。我也经历过这个阶段,那次教训让我明白了两件事:
第一,认证不是前端的门面功夫,而是后端数据的第一道闸门。前端做的任何登录状态判断,本质上都只是用户体验的一部分,真正的安全边界必须落在服务端。哪怕你前端把登录页做得再漂亮、跳转再流畅,只要后端接口没校验身份,一切等于零。
第二,用户认证是一个完整的链路,不是单点功能。它至少包含注册、登录、会话管理、Token签发与刷新、退出登录、访问控制这六个环节,任何一个环节偷工减料,整个系统的安全性都会被拉低到那个最薄弱的环节上。
这也是我后来给这个内部工具从头实现用户认证时,给自己定的底线:不做半吊子方案,六个环节一个都不能省。
1.2 认证、授权、会话:先把三个概念分清
在设计之前,我建议每个开发者都把这三个词在脑子里重新过一遍,因为它们太容易混在一起了:
- 认证(Authentication):回答“你是谁”。登录输入用户名密码、扫码、短信验证码,本质都是认证过程。认证通过之后,系统才信任这个请求来自某个具体用户。
- 授权(Authorization):回答“你能干什么”。认证通过不等于什么都能做。普通用户和管管理员虽然都通过了登录,但能访问的接口、能看到的数据完全不同。
- 会话(Session):回答“我凭什么一直记住你”。HTTP 协议本身是无状态的,每次请求都是独立的,会话机制就是让服务端在多次请求之间记住“这个人已经认证过了”。
我用生活化的例子来解释这三者的关系:小区门口刷脸进大门是“认证”,说明你是这个小区的住户;但你刷完脸只能进自己那栋楼,进不了配电房和机房,这是“授权”;门卫看到你眼熟,隔几分钟第二次进出不用再刷脸,这是“会话”。
很多项目在认证的沟里翻车,不是不会写代码,而是把这三个概念揉在一起设计,比如登录之后顺手把权限也写死进前端,或者干脆不做会话失效机制。这次重构我的第一件事,就是先把这三个边界画清楚,后面每写一段代码都对号入座。
2. 方案选型:Session-Cookie 还是 JWT,我为什么这次选了 JWT
2.1 两种方案的底层逻辑对比
用户认证的第一步,是先选会话载体。主流的方案就两个:Session-Cookie 和 JWT,谈不上谁绝对碾压谁,但适用场景差异很大。
Session-Cookie 的思路:用户登录后,服务端在内存或 Redis 里生成一份会话记录(session),并把 sessionId 通过 Set-Cookie 下发给浏览器。之后每次请求浏览器自动带上 Cookie,服务端拿到 sessionId 去查自己的存储,查到了就说明这个请求是认证过的。
这套机制的核心是:会话状态在服务端。它最大的优点是好吊销——你在服务端把这个 session 删掉,用户立刻失效。缺点是分布式部署时麻烦,多台服务器得共享同一份 session 存储,要么用 Redis 做集中式存储,要么做粘性会话,否则用户在 A 机器登录,请求打到 B 机器就找不到会话了。
JWT 的思路:用户登录成功后,服务端签发一个经过签名、自包含身份的 JSON Token 给客户端。客户端每次请求把它放在 Authorization 头里,服务端只需要验证签名和过期时间,不需要查任何存储,就能确认这个请求的身份。
这套机制的核心是:会话状态在客户端。它最大的优点是天然适合水平扩展——服务端是无状态的,加机器也好、做负载均衡也好,不需要共享 session 存储。缺点也很明显:服务端无法主动让一个 JWT 失效,没到期之前你拿它没办法。
2.2 结合项目场景的选型决策表
我这次做的内部工具,有 Web 端,后续还要接移动端和第三方调用,API 要面向多个客户端提供服务。这个场景下我做了这样的对比:
| 维度 | Session-Cookie | JWT |
|---|---|---|
| 会话状态位置 | 服务端(内存/Redis) | 客户端(Token本身) |
| 水平扩展 | 需要共享存储或粘性会话 | 天然无状态,扩展容易 |
| 主动吊销 | 立即生效 | 需要额外黑名单机制 |
| 跨端适配 | Cookie 在移动端/API 场景别扭 | HTTP Header 通用,移动端友好 |
| 存储压力 | 会话越多,存储压力越大 | 服务端无存储压力 |
| 实现复杂度 | 简单直接 | 需要处理刷新与吊销逻辑 |
最终我选了 JWT,但采用了Access Token + Refresh Token 双 Token 方案。为什么是双 Token,而不是单个 JWT 用到底?原因后面展开,这里先给结论:纯 JWT 的主动吊销短板,可以通过把 Refresh Token 做成可吊销的、可轮换的机制来弥补;而 Access Token 保持短生命周期,让攻击窗口尽可能小。
2.3 技术栈约定与工程结构
为了让文章里的代码好读、好复现,我把后端实现固定在 Node.js + Express 这套组合上,数据库用 MySQL,Token 相关的库用 jsonwebtoken,密码哈希用 bcrypt。选这套组合没有特别玄学的理由,就是团队熟、生态全、社区踩坑资料多。但你完全可以把这套思路迁移到其他语言——核心逻辑是一样的,只是库的 API 名称不同。
工程结构上,我按业务功能做了简单分层:
src/ controllers/ # 登录、注册、刷新等接口的控制器层 middleware/ # 认证中间件、错误处理中间件 routes/ # 路由定义 services/ # 业务逻辑层,密码哈希、Token签发都在这层 utils/ # 工具函数这里强调一下分层的意义:认证相关的代码要集中管理,不要散落在各个业务接口里。我见过有些项目每个路由文件里各写一份解析 Token 的逻辑,最后想统一改一个算法或者换一套密钥,翻了十几个文件才改完,那体验太痛苦了。
3. 密码安全:哈希不是加密,选对算法才不算白做
3.1 为什么不能直接存明文,MD5 为什么也不行
密码存储是整个用户认证里最容易被低估的一环。很多人觉得“反正数据库一般不会被拖库”,于是明文存储甚至干脆写死在配置里。这种想法等于把保险箱的钥匙贴在保险箱外面。
数据库被拖这件事,很多时候不是你代码的问题,而是基础设施层面不可控的。一旦密码明文泄露,用户在其他平台如果用了同一套账号密码,连锁损失根本兜不住。所以我们能做的,是让泄露出去的密码没法被直接使用、也没法被快速逆向。
那么把密码做一下哈希不就行了吗?不完全是。常见的 MD5、SHA-1、SHA-256 这类哈希算法有个共同特征:速度极快。现代 GPU 暴力破解 MD5 的速度可以达到每秒数十亿次,哪怕你在后面加了一个固定盐(salt),黑客只要能拿到你的盐,照样可以在毫秒量级内批量尝试。这也是为什么 MD5 虽然看似“不可逆”,在实际破解工具面前基本形同虚设。
正确的方向是使用专门为密码设计的慢哈希算法,让每一次哈希计算都故意变慢。攻击者想暴力破解,就得按你的速度来,时间成本指数级上升。目前主流推荐三个:bcrypt、scrypt、Argon2。我这次选的是 bcrypt,原因很简单:使用广泛、库的质量稳定、默认行为可靠。
3.2 bcrypt 的 cost 参数怎么调,我实测下来的数据
bcrypt 的核心参数叫 cost factor,也叫 rounds,它直接决定哈希计算的工作量。bcrypt 的计算开销是 2 的 cost 次方,所以 cost 每加 1,耗时大约翻一倍。我实际在普通笔记本上跑了一组测试:
| cost | 单次哈希耗时(约) | 感受 |
|---|---|---|
| 8 | 10ms 左右 | 太快,安全性偏低 |
| 10 | 50-80ms | 比较均衡,适合绝大多数 Web 应用 |
| 12 | 250ms 左右 | 更安全,但用户登录时会感受到轻微延迟 |
我用 cost=10 作为默认值,原因是它能在安全性和用户体验之间取得一个比较好的平衡。如果你做的是高安全要求的系统,可以到 12;不建议无脑开到 15,单次哈希要好几秒,用户在登录页多等两秒流失率是很可观的。
Node.js 里的实际调用很简单:
const bcrypt = require('bcrypt'); // 注册时:生成密码哈希 const saltRounds = 10; const passwordHash = await bcrypt.hash(password, saltRounds); // 登录时:校验密码 const isMatch = await bcrypt.compare(password, storedHash); if (!isMatch) { // 返回统一的“用户名或密码错误”,不要告诉攻击者到底是哪个错 }这里有个操作细节经常被忽略:不要自己拼盐。bcrypt 的 hash 结果里已经包含了盐的信息,verify 的时候它会自己从哈希串里取盐来校验,你完全不需要管盐怎么存、怎么传。自己造轮子拼盐,反而容易弄出“盐硬编码”“盐重复使用”这种低级但致命的错误。
3.3 注册流程里那些不起眼但影响很大的细节
注册接口看似简单,就是“收用户名密码,存进数据库”,但有几个细节以后会反复困扰你:
一是用户名唯一性校验的归一化处理。用户在注册时输入Alice,另一个用户输入alice,到底算不算同一个账号?建议在注册和登录时都做统一处理,比如用户名一律转小写再查重。不然就会出现同一个用户名因为大小写不同注册两次,登录时匹配逻辑混乱的诡异问题。
二是邮箱验证要不要做。如果你不做邮箱验证,用户随便填一个不存在的邮箱也能注册,后续密码找回功能就名存实亡。我这次给内部工具加的是“可选邮箱 + 注册后发验证链接”,对于内部系统可以放宽,对外部用户开放的系统建议必选。
三是密码策略不要过度复杂。早年很多站点要求密码必须包含大写、小写、数字、特殊符号,结果用户的密码都是Abc@1234这种有规律的变体,反而更好猜。现在的通行做法是:至少 8 位、不限制太多组合规则、允许用户用长短语当密码。长密码带来的安全性提升,要远高于强制塞一个感叹号的收益。
四是注册接口要限流。防止有人用脚本批量注册账号。按 IP + 设备指纹维度做简单的频率限制,注册失败超过阈值就临时封一段时间的 IP,这个逻辑几行代码就能实现,但很多项目直到被刷注册才想起来补。
4. 登录、Token签发与刷新:从接口设计到落地实现
4.1 登录接口的设计:校验、限流、返回结构
登录接口是整个认证链路的核心入口。它要做的逻辑其实就三步:查用户、验密码、发 Token。但每一步旁边都挂着几个“否则”,一个不小心就给攻击者留了后门。
先说限流。登录接口必须做防暴力破解,这个没有商量余地。我按 IP + 用户名两个维度各做了一组限制:同一个 IP 一小时内最多失败 30 次,同一个用户名一小时内最多失败 10 次。超过阈值就锁定一段时间,并返回一个用户可理解的提示。这个逻辑可以直接用现成的限流中间件实现,也可以在数据库里记录登录失败日志后自己判断,二选一即可,但绝不能裸奔。
登录成功后的返回结构,我用的是:
{ "accessToken": "eyJhbGciOiJIUzI1NiIs...", "refreshToken": "d3a2f8c1e9b4...", "expiresIn": 1800, "user": { "id": "10001", "username": "alice", "role": "admin" } }expiresIn的单位是秒,这里 1800 表示 Access Token 半小时后过期。把这个字段显式返回给前端,是为了让前端能提前安排刷新时机,而不是傻傻等接口报 401 才反应。
登录失败时,我统一返回用户名或密码错误,不区分是“用户不存在”还是“密码不对”。这是一条安全铁律:如果接口告诉你“该用户不存在”,攻击者就可以批量试探出哪些账号是真实存在的,后续的定向攻击就方便了。
接口本身的伪代码长这样:
router.post('/login', loginRateLimiter, async (req, res) => { const { username, password } = req.body; // 1. 查用户,注意这里用归一化后的用户名 const user = await userService.findByUsername(username.trim().toLowerCase()); if (!user) { return res.status(401).json({ message: '用户名或密码错误' }); } // 2. 校验密码 const isMatch = await bcrypt.compare(password, user.passwordHash); if (!isMatch) { return res.status(401).json({ message: '用户名或密码错误' }); } // 3. 签发双 Token const { accessToken, refreshToken } = await tokenService.issueTokens(user); return res.json({ accessToken, refreshToken, expiresIn: 1800, user: safeUser(user) }); });safeUser(user)这一步也很容易漏:返回给前端的用户对象,绝不能带 passwordHash、salt 这类敏感字段。我习惯在查询后显式构造一个白名单字段对象,而不是把整个数据库行直接丢回去。
4.2 Access Token 与 Refresh Token 的分工逻辑
为什么非要拆成两个 Token?一句话回答:Access Token 越短命越安全,但越短命用户体验越差,Refresh Token 就是用来调和的。
Access Token 是真正带着身份去访问业务接口的凭证,它泄露导致的危害最大,所以生命周期要短。我设的是 30 分钟,敏感系统可以压到 15 分钟甚至 10 分钟。Refresh Token 的生命周期可以长得多,我设的是 7 天,它只在一种场景下出场——Access Token 过期时用来换取新的 Access Token。
这两个 Token 的生存策略完全不同:
- Access Token 是无状态的 JWT,服务端不保存任何信息,验证签名和过期时间即可。
- Refresh Token 我选择存在数据库(或者 Redis)里,生成一串随机字符串,而不是签发一个 JWT。为什么?因为后续做吊销、做轮换都有据可查。
签发 JWT 的代码里,我要特别强调一下 payload 的设计。JWT 的 payload 是 Base64 编码的,不是加密的,任何人都可以解码看到里面的内容,所以绝对不要往里面塞密码、手机号、身份证号这类敏感信息。我只放四个字段:
const jwt = require('jsonwebtoken'); function signAccessToken(user) { return jwt.sign( { sub: user.id.toString(), // 用户ID name: user.username, // 用户名,仅用于展示,不作为鉴权依据 role: user.role, // 角色,用于授权判断 type: 'access' // 标识这是 Access Token }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '30m', algorithm: 'HS256' } ); }私钥管理也要说一句:开发环境的密钥和线上环境的密钥必须完全分开,线上密钥要通过环境变量或密钥管理服务注入,绝不能写进代码仓库。我见过有项目把 JWT 密钥提交到 Git 仓库里,那等于给所有能看代码的人发了万能钥匙。
Refresh Token 的生成和存储逻辑:
const crypto = require('crypto'); async function issueRefreshToken(user) { // 生成一个不可预测的随机字符串 const rawToken = crypto.randomBytes(48).toString('hex'); // 存库,记录关联用户和过期时间 await db.query( 'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)', [user.id, hashToken(rawToken), new Date(Date.now() + 7 * 24 * 3600 * 1000)] ); return rawToken; }注意这里我存的是token_hash而不是原始 token。原因和存密码一个道理:这串 refreshToken 本身就像一把长钥匙,如果数据库泄露,拿到了原始 token 就等于拿到了用户长期的登录凭证,所以存库前也做哈希,至少衰减掉“一次泄露全部沦陷”的风险。
4.3 退出登录后,Token 怎么才能真正失效
前面说过,纯 JWT 最大的短板是服务端无法主动吊销。这里我结合双 Token 方案给出完整的处理策略:
Access Token 的失效靠短命。30 分钟过期,就算泄露或者被吊销,攻击窗口被压到了可接受的范围,不需要额外处理。如果你对某些高风险操作(比如修改密码、支付)有更高要求,可以单独给这些接口加一个更高优先级的校验,要求携带一个一次性短期 Token,但这属于授权层的加强,不在基础认证范围内。
Refresh Token 的失效靠主动吊销。退出登录的核心动作,就是把这个用户的 Refresh Token 从数据库里删掉。
router.post('/logout', authenticate, async (req, res) => { const userId = req.user.sub; const refreshToken = req.body.refreshToken; // 删除该用户的所有 refresh token(或者只删当前这一个) await db.query( 'DELETE FROM refresh_tokens WHERE user_id = ?', [userId] ); res.json({ message: '已退出登录' }); });我这里是按user_id把所有 Refresh Token 都删了,相当于“这个用户的所有设备全部下线”。如果你想做“单设备退出”,就只删当前请求携带的那一个,但那样你就得先拿到原始 refreshToken 再算出 hash 去匹配删除。两种策略各有用武之地,我这次选的是全清,省事且符合内部工具的使用习惯。
这里要特别提醒:前端退出登录时,不要只清掉本地的 Token 就完事。你本地清掉了,服务端手里的 Refresh Token 还是活的,等于用户名义上退出了,实际会话仍然有效。正确做法一定是调后端接口,让服务端把会话凭证吊销。
5. 前端与拦截器联动:把身份信息附加到每一次请求
5.1 前端 Token 存储:localStorage 到底能不能用
后端把 Token 签发好了,前端面临的下一个问题就是:放哪里?
我知道不少文章一上来就批判localStorage存储 Token 不安全,说只要被 XSS 注入脚本就能偷走。这个说法没错,但要放在场景里看。如果你的应用本身 XSS 防护做得稀烂,Token 放在哪都会出事——放在 Cookie 里可以被 CSRF 利用,放在内存里刷新页面就丢。所以这个问题没有绝对答案,只有取舍:
- localStorage:简单直接,刷新页面不丢,但 XSS 脚本可以读取。
- 内存变量:刷新页面就丢,需要重新走一遍登录或刷新流程,但 XSS 读不到。
- Cookie + HttpOnly + SameSite:浏览器自动携带、脚本读不到,但需要额外防 CSRF,且移动端等非浏览器客户端不方便。
我这次采用的是一个折中方案:Access Token 放内存变量,Refresh Token 放 HttpOnly Cookie。这样一个刷新页面后 Access Token 虽然没了,但前端可以拿 HttpOnly Cookie 里保留的 Refresh Token 去静默换新;如果应用被 XSS 攻破,脚本只能偷到半小时有效的 Access Token,偷不到长命的 Refresh Token。安全性和体验都兼顾了。
5.2 请求拦截器与响应拦截器的标准写法
前端这边我用的 axios 做请求库,思路在其他请求库上也通用。核心就两步:请求时塞上 Access Token,响应 401 时触发刷新重放。
请求拦截器:
import axios from 'axios'; const api = axios.create({ baseURL: '/api' }); // 维护内存中的 access token let accessToken = null; export function setAccessToken(token) { accessToken = token; } api.interceptors.request.use((config) => { if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } return config; });响应拦截器是重点。它要能判断“这个 401 是不是因为 Token 过期”,而不是无脑刷新——有些接口本来就会返回 401(比如密码错误的时候),你冲上去刷一轮 Token 就是画蛇添足。
api.interceptors.response.use( (response) => response, async (error) => { const { response, config } = error; if (response?.status === 401 && !config._retried) { config._retried = true; try { const refreshed = await refreshAccessToken(); setAccessToken(refreshed.accessToken); return api(config); // 重放原来的请求 } catch (refreshError) { // 刷新失败,跳转登录页 window.location.href = '/login'; return Promise.reject(refreshError); } } return Promise.reject(error); } );config._retried这个标记必须加,否则同一个请求刷新失败后会无限循环重试,直接把浏览器卡死。这个坑我踩过,后面细说。
刷新接口本身走的是独立请求实例,不要在业务实例里递归调用自己,否则会进入死循环。
5.3 并发 401 时的刷新风暴:多请求怎么排队
运行一段时间后你会发现一个新的问题:用户打开应用时,页面同时发出三五个请求,Access Token 恰好刚过期,于是五个请求同时返回 401,前端代码就会同时发起五次刷新请求。服务端的 Refresh Token 如果实现了“一次性轮换”,这五次刷新里只有一次能成功,其余四次全失败,用户直接被踢下线。
解决办法是做一个单例刷新 Promise:不管多少个请求同时 401,都复用同一个刷新 Promise,第一个请求负责发刷新请求,其余请求等着 Promise settle 之后再重放自己的原请求。
let refreshPromise = null; async function refreshAccessToken() { if (!refreshPromise) { refreshPromise = axios.post('/auth/refresh', {}, { withCredentials: true }) .then((res) => { setAccessToken(res.data.accessToken); return res.data; }) .finally(() => { refreshPromise = null; }); } return refreshPromise; }这样“并发刷新风暴”就被一个变量消掉了:同一时刻只有一个刷新请求在网络上行进,其他请求排队等结果,拿到新 Token 后原请求自动重放,用户完全无感知。
6. 我在这套认证系统里踩过的真实坑
6.1 服务器时间偏差导致 Token 提前过期
JWT 的过期时间依赖服务器的系统时钟来验证。我遇到过一次很奇怪的现象:测试环境里 Token 明明设置了 30 分钟有效,但实际到 29 分钟的时候接口就开始报 401。排查了一圈,最后发现是开发机的系统时间比正常时间快了一分多钟,NTP 同步被关掉了。
这个坑在单机开发时很难发现,因为签发和验证都在同一台机器上,时间偏差被“自我抵消”了。一旦上了多服务器环境,签发 Token 的服务器和验证 Token 的服务器如果时间不一致,就会出现“A 签发的 Token,在 B 那里验证直接过期”的现象。
解决方案有两个:一是所有服务器统一启用 NTP 时间同步;二是 JWT 校验时允许一个小的时钟偏移量,jsonwebtoken库里可以这样配置:
jwt.verify(token, secret, { clockTolerance: 30 // 允许 30 秒的时间偏移 });两者配合,基本可以把这个坑填平。建议从一开始就给所有验证 JWT 的环境加上clockTolerance,它不影响正常场景,只给时间不同步留了一个缓冲垫。
6.2 并发刷新时 Refresh Token 被轮换机制“误杀”
我做 Refresh Token 轮换时,一开始的逻辑是:每次用旧的 Refresh Token 换新 Token,老的那个立即标记为已使用、作废。这个设计本意是防 Token 泄露后被重复使用,本身没问题,但在没有前端并发控制的环境里差点出事。
具体现象是:用户开着两个浏览器标签页工作,标签 A 的 Access Token 过期了,自动触发一次刷新,拿到了一组全新的 Token;标签 B 也恰好过期了,也拿着旧 Refresh Token 去刷新。由于标签 A 刷新后,旧 Refresh Token 已经被标记使用了,标签 B 的刷新请求就被服务端判定为“刷新 Token 被重复使用”,直接把这个用户标记为疑似泄露,吊销了全部 Refresh Token。
解决这个问题的标准做法,就是我上一节写的前端单例刷新 Promise,确保同一时间只发一个刷新请求。但这也只是前端层面的缓解,更稳妥的服务端方案是:Refresh Token 轮换时不做“立即作废旧令牌”,而是做“宽限期”处理——旧 Refresh Token 在很短的时间窗口内(比如 30 秒)仍然允许使用,但每次使用都会刷新它的过期时间。这样并发场景下不会误杀正常用户,而真正长时间重复使用的泄露行为仍然会被拦截。
6.3 日志和错误信息里藏着认证系统的“后门”
最后一个坑不是功能性问题,但危害最大:日志泄露。
有一次我在调试线上问题,顺手翻了应用日志,结果发现登录接口的参数被完整打印了出来,包括用户名和密码明文。那一刻真的后背发凉——如果日志系统被攻破,等于密码库直接送给攻击者。还有一次是统一异常处理直接把数据库异常信息抛回前端,用户看到一条 SQL 报错,不仅体验差,还等于把数据库类型、表名、字段结构都告诉了想攻击的人。
我现在对认证相关代码定了几条铁律:
- 业务日志里禁止打印密码、passwordHash、Token 完整值。Token 最多打印后四位,用于定位问题时区分是哪一次签发的。
- 接口报错信息对外必须脱敏。统一在错误处理中间件里兜底,返回给客户端的只有错误码和通用提示,内部详细堆栈写进服务端日志,不对外暴露。
// 统一错误处理中间件 app.use((err, req, res, next) => { // 内部记录完整错误 console.error(`[${req.method}] ${req.path} - ${err.message}`, { stack: err.stack }); // 对外返回脱敏信息 res.status(err.status || 500).json({ code: err.code || 'INTERNAL_ERROR', message: err.status ? err.message : '服务器内部错误' }); });这里的原则总结成一句话:敏感信息要么不产生,要么只存在系统深处,绝不能让它出现在日志、错误响应、前端控制台这类“半公开”的区域里。
我前后把用户认证这套东西重构过三次,每次绕一圈回来都发现,真正决定系统安全高度的不是用了多高级的算法,而是边界处理有多仔细:密码有没有存对、刷新有没有防并发、退出有没有真失效、日志有没有泄露。先把这些地基打牢,再去折腾花哨的登录方式也不迟。如果你正在做自己的认证模块,建议从这套流程开始,跑通之后再按业务场景去增删细节。