首页 / Node.js 教程 / JWT 鉴权实战

Node.js 教程

JWT 鉴权实战

本教程共 76 篇 · 第 56 篇 · 更新于 2026-07-25 · 约 9 分钟阅读

Node.jsJWT鉴权token认证

56. JWT 鉴权实战

本节目标:- JWT 长什么样,为什么能被「验真」 - 用 jsonwebtoken 签发和校验令牌 - 登录、保护路由、刷新令牌的完整实战 - JWT 方案里那些容易翻车的安全点

上一章讲了 Session:服务端存状态,Cookie 只带一把 session ID。这套方案稳,但「状态在后端」这件事在有些场景里挺碍事——比如你有好几个独立部署的服务,或者要同时给网页、App、小程序发令牌。每次请求都去查一遍 Redis,架构上就绑死了。

JWT(JSON Web Token,读作「 jot」)走的是另一条路:无状态。它把「你是谁、有什么权限」直接编码进令牌本身,用签名保证不被篡改。服务端不存任何东西,拿到令牌验一下签名就信了。

JWT 的结构

一个 JWT 是三段 base64url 字符串,用点号连起来:xxxxx.yyyyy.zzzzz

  • Header(头):声明类型和签名算法,比如 { "alg": "HS256", "typ": "JWT" }
  • Payload(载荷):塞你自己想带的数据,比如用户 ID、角色、过期时间。
  • Signature(签名):用密钥对「前两段」做签名算出来的,用来防篡改。

关键是第三段的签名。因为签名是基于前两段内容 + 密钥算的,只要有人改了 payload 里的任何字符,服务端重新算签名就会发现对不上,直接拒掉。所以你不能在 payload 里放密码、身份证号这类敏感信息——它只是被编码(base64),不是被加密,谁都能解开看。

import jwt from 'jsonwebtoken';

const token = jwt.sign(
  { userId: 7, role: 'admin' },
  '你的密钥',
  { expiresIn: '15m' }
);

console.log(token); // eyJhbGciOiJIUzI1NiJ9.xxxxx.yyyyy
Note

jsonwebtoken 目前主流是 9.x。如果你想要一个纯 JS、基于 Web Crypto、对 TypeScript 和原生运行 TS 更友好的选择,可以看看 jose 这个库——它在 Node.js v24 上能直接跑,不需要任何原生依赖。下面示例用 jsonwebtoken,因为生态最成熟。

登录签发令牌

完整的登录流程是:校验账号密码 → 密码对了就签发令牌。密码比对要用第 30 章讲过的 cryptoscrypt / timingSafeEqual,别用明文比对着玩。

import express from 'express';
import jwt from 'jsonwebtoken';

const app = express();
app.use(express.json());

const SECRET = process.env.JWT_SECRET; // 务必从环境变量读

// 假装的用户表
const users = [{ id: 7, username: 'tom', passwordHash: '...' }];

app.post('/login', (req, res) => {
  const { username, password } = req.body;
  const user = users.find(u => u.username === username);
  if (!user) {
    return res.status(401).json({ error: '账号或密码错误' });
  }
  // 这里省略真正的密码比对,请务必用 scrypt + timingSafeEqual
  const token = jwt.sign(
    { userId: user.id, role: 'user' },
    SECRET,
    { expiresIn: '15m' }
  );
  res.json({ token });
});

中间件保护路由

签了令牌,前端之后每次请求要在请求头里带上它,格式是 Authorization: Bearer <token>。我们写一个中间件,在需要登录的路由前面验令牌。

function authRequired(req, res, next) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;

  if (!token) {
    return res.status(401).json({ error: '未提供令牌' });
  }

  try {
    const payload = jwt.verify(token, SECRET);
    req.user = payload; // 把解码出的信息挂到请求上,后续 handler 直接用
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: '令牌已过期' });
    }
    return res.status(401).json({ error: '令牌无效' });
  }
}

app.get('/me', authRequired, (req, res) => {
  res.json({ userId: req.user.userId, role: req.user.role });
});

jwt.verify 会干两件事:核对签名有没有被改过,以及检查 exp 过期时间到了没。任一条不过就抛错,我们按错误类型返回不同提示。

Tip

别在中间件里只调 jwt.decode——decode 只解码不验签,等于没穿衣服就信了 payload。保护路由必须用 verify

刷新令牌:access token 短命,refresh token 长命

上面 expiresIn: '15m' 给 access token 设了 15 分钟过期,这是好事——令牌泄露后窗口很短。但用户总不能每 15 分钟重新登一次录。解决办法是发两种令牌:

  • access token:短期(15 分钟),用来调业务接口。
  • refresh token:长期(比如 7 天),只用来换新的 access token。

刷新令牌通常要更小心地存,主流做法是放进 httpOnly + secure 的 Cookie 里,前端 JS 读不到,能挡 XSS 偷令牌;再配合 sameSite 防 CSRF。

app.post('/login', (req, res) => {
  // 校验密码后...
  const accessToken = jwt.sign({ userId: user.id }, SECRET, { expiresIn: '15m' });
  const refreshToken = jwt.sign({ userId: user.id }, SECRET, { expiresIn: '7d' });

  // refresh token 种进 Cookie(前端 JS 读不到)
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });

  res.json({ accessToken }); // access token 直接回 JSON 给前端存着
});

app.post('/refresh', (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token) return res.status(401).json({ error: '无刷新令牌' });

  try {
    const payload = jwt.verify(token, SECRET);
    const accessToken = jwt.sign({ userId: payload.userId }, SECRET, { expiresIn: '15m' });
    res.json({ accessToken });
  } catch {
    res.status(401).json({ error: '刷新令牌无效' });
  }
});
Warning

光发 refresh token 还不够「可治理」。理想情况下 refresh token 应该在服务端有个「吊销名单」(存 Redis,用户退出就删掉对应记录),否则令牌一旦泄露,在 7 天有效期内服务器都没法让它提前失效。这是 JWT 无状态带来的固有代价——想能随时作废,就又得往服务端存点东西,正好和 Session 思路汇合了。

对称还是非对称密钥

上面用的都是 HS256,属于对称算法——签发和校验用的是同一个 SECRET。这在单体应用里没问题,但如果你有多个服务都要校验令牌,就得把同一把密钥分发到各处,任何一处泄露全完蛋。

更讲究的做法是用 RS256 这种非对称算法:你持有一把私钥(private key)只用来签发,把对应的公钥(public key)分发给所有需要校验的服务。公钥只能验不能签,即使泄露也无法伪造令牌。

import fs from 'node:fs';
import jwt from 'jsonwebtoken';

const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');

// 签发用私钥
const token = jwt.sign({ userId: 7 }, privateKey, { algorithm: 'RS256', expiresIn: '15m' });
// 校验用公钥,且必须指定算法,杜绝 alg:none 攻击
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
Tip

用非对称算法时,verify 一定要传 algorithms 白名单。因为如果让客户端用 JWT 头里的 alg 决定算法,历史上出现过把 alg 改成 none 直接绕过的漏洞。锁定算法是必须养成的习惯。

前端把 access token 存哪

JWT 到了前端,总得有个地方放着方便每次请求带上。最常见的两个选择:

  • localStorage:用起来方便,但任何能执行 JS 的地方(XSS 漏洞)都能读走它。一旦令牌被偷,攻击者在 15 分钟内畅通无阻。
  • httpOnly Cookie:前端 JS 读不到,天然抗 XSS 偷窃,但要配合 sameSite + CSRF 防护,而且跨域场景下 Cookie 携带更麻烦。

没有绝对正确的答案。对内网、低风险场景,localStorage 够用;对面向公众的、安全要求高的应用,把 access token 放内存、refresh token 放 httpOnly Cookie,是当下比较稳的折中。

令牌吊销:无状态带来的代价

前面反复提过,JWT 无状态的好处也正好是它的软肋:服务器不存任何东西,自然也就「不知道」某个令牌是不是已经被用户主动退出、或者账号已经被冻结。一个在过期前的令牌,服务器验签通过就会放行。

实际项目里常用两种补救:

  • 短过期 + 刷新:把 access token 设得极短(比如 5~15 分钟),即使泄露,攻击者能用的窗口也很小。真正「长效」的是 refresh token,而你把 refresh token 的黑名单放在 Redis,用户退出就删掉对应记录。这样「退出登录」实际干掉的是刷新能力,旧 access token 过期后自然失效。
  • 布隆过滤器 / 黑名单:对安全要求极高的场景,把「已吊销的 access token 的 jti(JWT ID)」记进 Redis,每次校验时先查一遍黑名单。这增加了一次存储查询,但换来了即时吊销能力。

说到底,纯无状态是理想,真实业务往往要在「无状态」和「可治理」之间找个平衡点。别被「JWT 就是不要存任何东西」这句话绑死。

几个常见翻车点

1. 算法要显式指定,别让客户端说了算。 JWT 头部里的 alg 字段如果由客户端控制,历史上有过 alg: "none" 的绕过攻击——攻击者把算法改成 none,服务端就不验签直接放行。用 jsonwebtokenverify 的第二个参数传固定密钥即可,它会按你给的算法验,不接受头里的 none。如果你用非对称密钥(RS256),更要锁死算法。

2. 密钥强度。 HS256 用的是对称密钥,你得自己保证它够长够随机。直接写 secret: '123' 等于没锁门。

3. 别往 payload 塞敏感信息。 记住它是 base64 编码不是加密,谁拿到都能解码看。

4. 短期 access token。 access token 尽量短(15 分钟甚至更短),即便泄露影响也有限。

Note

JWT 不是 Session 的替代品,而是另一种权衡。Session 适合「要能随时踢人、状态集中管」的传统 Web 应用;JWT 适合「多端、多服务、不想维护共享存储」的 API 场景。选型看你的架构,而不是哪个更「潮」。

把令牌签发、路由保护、刷新这三块串起来,你就拥有了一套能直接上线的鉴权骨架。但光有鉴权还不够——万一接口能被随便跨域调用,或者响应头大开方便之门,令牌再牢也白搭。下一章讲 CORS 和安全响应头。