
JWT 与 Session:何时使用哪种
没有一种方案是普遍更好的——正确的选择取决于你的架构。
| JWT | Session | |
|---|---|---|
| 存储 | 客户端(localStorage/cookie) | 服务器(Redis/DB) |
| 可扩展性 | 水平(无状态) | 需要共享存储 |
| 撤销 | 困难(短 TTL + 黑名单) | 容易(从存储中删除) |
| 载荷 | 携带数据 | 仅一个 ID |
| 移动端友好 | 是 | 需要 Cookie |

安全的 JWT 实现
// auth/jwt.js
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
const ACCESS_TOKEN_TTL = '15m';
const REFRESH_TOKEN_TTL = '7d';
export function generateTokenPair(userId, role) {
const accessToken = jwt.sign(
{ sub: userId, role, type: 'access' },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: ACCESS_TOKEN_TTL, algorithm: 'HS256' }
);
const refreshToken = jwt.sign(
{ sub: userId, jti: crypto.randomUUID(), type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: REFRESH_TOKEN_TTL, algorithm: 'HS256' }
);
return { accessToken, refreshToken };
}
export function verifyAccessToken(token) {
return jwt.verify(token, process.env.JWT_ACCESS_SECRET);
}
export function verifyRefreshToken(token) {
return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
}
刷新令牌轮换
// routes/auth.js
import { redis } from '../lib/redis.js';
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
const payload = verifyRefreshToken(refreshToken);
const { sub: userId, jti } = payload;
// 检查令牌是否已被撤销(使用过或列入黑名单)
const isRevoked = await redis.get(`revoked:${jti}`);
if (isRevoked) {
// 可能令牌被盗——使该用户的所有刷新令牌失效
await redis.del(`user:${userId}:refresh_tokens`);
return res.status(401).json({ error: 'Token reuse detected' });
}
// 撤销已使用的刷新令牌
await redis.set(
`revoked:${jti}`,
'1',
'EX',
7 * 24 * 60 * 60 // 7 天
);
// 颁发新的令牌对
const { accessToken, refreshToken: newRefreshToken } = generateTokenPair(userId);
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return res.json({ accessToken });
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
认证中间件
export function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.slice(7);
try {
const payload = verifyAccessToken(token);
req.user = { id: payload.sub, role: payload.role };
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
// 基于角色的访问控制
export function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user?.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
基于 Session 的认证(使用 Redis)
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
name: 'sid',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
},
}));
// 登录
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await verifyCredentials(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 重新生成 session 以防止 session 固定攻击
req.session.regenerate((err) => {
if (err) return next(err);
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: 'Logged in' });
});
});
// 登出
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
res.clearCookie('sid');
res.json({ message: 'Logged out' });
});
});
使用 Argon2 进行密码哈希
import argon2 from 'argon2';
// 注册时哈希密码
export async function hashPassword(password) {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
}
// 登录时验证密码
export async function verifyPassword(hash, password) {
return argon2.verify(hash, password);
}
安全检查清单
- 使用
httpOnly+secure+sameSiteCookie 存储刷新令牌 - 每次使用后轮换刷新令牌
- 检测令牌重用(令牌被盗)
- 使用 Argon2id 进行密码哈希
- 对认证端点进行速率限制(5 次尝试 / 15 分钟)
- 记录所有认证事件以供审计
- 在 JWT 载荷中存储最少数据
- 使用短生命周期的访问令牌(15 分钟)
- 实现登出(令牌黑名单或 session 删除)