自己写全栈项目,前端画页面不难,难的是登录。我第一版把 token 塞进 localStorage,过期时间设了七天,看起来能跑。直到有次线上排查 XSS,发现任何人都能用一段脚本把这七天的凭证捞走——那之后我把整套方案重做了。
结论先放这:access token 短命且无状态,refresh token 长命且可撤销,两个都放 HttpOnly Cookie。
为什么是两个 Token
单 token 的死结在于:想安全就得短,想体验好就得长,两者不能兼得。
拆成两个之后,各自只干一件事:
| access token | refresh token | |
|---|---|---|
| 有效期 | 15 分钟 | 14 天 |
| 存放 | HttpOnly Cookie | HttpOnly Cookie |
| 校验方式 | 验签即可,不查库 | 查库,且用完即换 |
| 泄露后果 | 15 分钟内有效 | 可立即吊销 |
15 分钟意味着即使被截获,窗口也很小;而长期凭证能随时被服务端注销,用户改密码、退出登录都能立刻生效。
签发逻辑
import jwt from 'jsonwebtoken';
import crypto from 'node:crypto';
const ACCESS_SECRET = process.env.ACCESS_SECRET;
const ACCESS_TTL = '15m';
const REFRESH_TTL_DAYS = 14;
function issueAccessToken(user) {
return jwt.sign(
{ sub: String(user.id), role: user.role },
ACCESS_SECRET,
{ expiresIn: ACCESS_TTL, algorithm: 'HS256' }
);
}
async function issueRefreshToken(db, userId, meta) {
// 存哈希,不存明文:库被拖了也换不出可用凭证
const raw = crypto.randomBytes(32).toString('base64url');
const hash = crypto.createHash('sha256').update(raw).digest('hex');
await db.insert('refresh_tokens', {
user_id: userId,
token_hash: hash,
user_agent: meta.userAgent,
ip: meta.ip,
expires_at: new Date(Date.now() + REFRESH_TTL_DAYS * 864e5),
});
return raw;
}
两个细节值得强调。第一,refresh token 用随机串而不是 JWT,因为它本来就要查库,没必要再背一份签名开销。第二,入库前先哈希,这点和存密码是同一个道理。
Cookie 配置
Cookie 配置错了,前面的设计全白搭:
const cookieBase = {
httpOnly: true, // JS 读不到,XSS 拿不走
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', // 防 CSRF 的第一道闸
path: '/',
};
res.cookie('at', accessToken, { ...cookieBase, maxAge: 15 * 60 * 1000 });
res.cookie('rt', refreshToken, { ...cookieBase, path: '/api/auth/refresh', maxAge: 14 * 864e5 });
注意 rt 的 path 收窄到了刷新接口。这样浏览器只会在这一个请求上带 refresh token,其它接口即便被注入脚本也没法顺手读走它。
sameSite: 'lax' 覆盖了绝大多数场景。如果你的前端和后端不同域,需要 sameSite: 'none' + secure: true,这时候务必补上 CSRF token,因为浏览器的默认防护已经让位了。
校验中间件
function requireAuth(req, res, next) {
const token = req.cookies?.at;
if (!token) return res.status(401).json({ code: 'NO_TOKEN' });
try {
// 显式限定算法,堵掉 alg 伪造
const payload = jwt.verify(token, ACCESS_SECRET, { algorithms: ['HS256'] });
req.user = { id: Number(payload.sub), role: payload.role };
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ code: 'BAD_TOKEN' });
}
}
algorithms: ['HS256'] 这一行是必须写的。早期 JWT 库读到 alg: none 会直接放行,虽然现在默认拦了,但显式声明总归是零成本的保险。
区分 TOKEN_EXPIRED 和 BAD_TOKEN 也有用:前端只在前者触发刷新,后者直接清凭证跳登录,避免拿伪造 token 去反复打刷新接口。
刷新时的旋转
refresh token 每用一次就作废,换一个新的,这是拦截重放的关键:
router.post('/refresh', async (req, res) => {
const raw = req.cookies?.rt;
if (!raw) return res.status(401).json({ code: 'NO_REFRESH' });
const hash = crypto.createHash('sha256').update(raw).digest('hex');
const row = await db.findRefreshToken(hash);
if (!row) return res.status(401).json({ code: 'REFRESH_REVOKED' });
if (row.used_at) {
// 同一个 token 被用了两次:大概率是泄露后被重放,全量吊销
await db.revokeAllForUser(row.user_id);
return res.status(401).json({ code: 'REPLAY_DETECTED' });
}
if (row.expires_at < new Date()) {
return res.status(401).json({ code: 'REFRESH_EXPIRED' });
}
await db.markUsed(row.id);
const user = await db.findUser(row.user_id);
const next = await issueRefreshToken(db, user.id, {
userAgent: req.get('user-agent'),
ip: req.ip,
});
res.cookie('at', issueAccessToken(user), { ...cookieBase, maxAge: 15 * 60 * 1000 });
res.cookie('rt', next, { ...cookieBase, path: '/api/auth/refresh', maxAge: 14 * 864e5 });
res.json({ ok: true });
});
row.used_at 那段是整套方案里我最喜欢的设计:攻击者拿到旧 token 来刷新,说明真凭证已经泄露,这时候全量吊销并让用户重新登录,比单纯拒绝请求更安全。
前端的并发问题
页面初始加载时并发发五个请求,access token 恰好过期,五个 401 一起打到刷新接口——这会导致第一个请求刷新成功、后四个拿着已作废的 refresh token 触发重放检测,用户被莫名其妙踢下线。
解决办法是让刷新请求全局唯一:
let refreshing = null;
async function request(url, options = {}) {
let res = await fetch(url, { ...options, credentials: 'include' });
if (res.status !== 401) return res;
const body = await res.clone().json().catch(() => ({}));
if (body.code !== 'TOKEN_EXPIRED') throw new Error('未登录');
// 同一时刻只允许一个刷新在飞,其余请求等它
refreshing ??= fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
}).finally(() => { refreshing = null; });
await refreshing;
return fetch(url, { ...options, credentials: 'include' });
}
refreshing ??= ... 加 finally 清空,就是最简的"单飞 + 复用"写法。注意重试只做一次,否则刷新接口本身 401 会变成死循环。
数据库表
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash CHAR(64) NOT NULL UNIQUE,
user_agent TEXT,
ip INET,
used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_rt_user ON refresh_tokens (user_id) WHERE revoked_at IS NULL;
CREATE INDEX idx_rt_expires ON refresh_tokens (expires_at);
保留 user_agent 和 ip 是为了做设备管理——用户能看到"我的登录设备",可疑的直接踢掉。清理过期记录挂个定时任务就行,别在请求路径上做。
上线前的检查项
- access token 有效期不超过 30 分钟
- refresh token 存哈希,且每次使用后旋转
- 两个 Cookie 都是
httpOnly,生产环境都带secure - refresh token 的
path收窄到刷新接口 jwt.verify显式指定algorithms- 前端刷新做单飞,且只重试一次
- 提供"退出所有设备",能全量吊销
这套方案跑了大半年没返工。它不复杂,复杂的是那些一开始图省事留下的口子——补起来比重写还累。