JWT Authentication in Express That You Can Actually Revoke
JWT Authentication in Express That You Can Actually Revoke
在 Express 中实现真正可撤销的 JWT 身份验证
Access tokens, refresh token rotation, and theft detection: the parts most Node.js tutorials leave out. A friend messaged me about his side project a few months ago: “Someone else is logged into my account. I changed my password. They’re still in.” 访问令牌(Access tokens)、刷新令牌轮换(refresh token rotation)以及盗窃检测:这些是大多数 Node.js 教程中遗漏的部分。几个月前,一位朋友给我发消息谈到他的副业项目:“有人登录了我的账户。我修改了密码,但他们仍然在线。”
He had followed the tutorials to the letter. Sign a JWT on login, send it to the frontend, keep it in localStorage, attach it to every request. Done. What none of those tutorials mentioned is that this setup has no way to un-log anyone in. A JWT is a signed piece of paper. Once you hand it over, it stays valid until it expires, and his expired in 30 days. 他完全照搬了教程的做法:登录时签发 JWT,发送给前端,存入 localStorage,并在每次请求时附带它。搞定。但没有教程提到,这种设置根本无法让用户强制下线。JWT 就像一张签名的纸条,一旦发出,它在过期前一直有效,而他的令牌有效期是 30 天。
Changing the password accomplished nothing, because the token had already been signed and nothing about it depended on the password. There was no list of active sessions to delete from. There was nothing to revoke. His only remaining move was rotating the signing secret, which logged out every user on the platform at once. That was his entire kill switch: burn it all down. 修改密码毫无作用,因为令牌早已签发,且其内容并不依赖于密码。系统中没有可供删除的活跃会话列表,也没有任何撤销机制。他唯一的办法就是更换签名密钥,但这会导致平台上所有用户同时下线。这就是他唯一的“紧急停止”开关:玉石俱焚。
This is the walkthrough I wish someone had handed me the first time I built auth. Token design, storage, refresh rotation, theft detection, the Express code, the Axios interceptor on the frontend, and the specific mistakes that turn a working login into an incident. It’s long. Auth is one of those areas where the missing ten percent is the part that gets you. 这是我第一次构建身份验证系统时,希望有人能提供给我的指南。涵盖了令牌设计、存储、刷新轮换、盗窃检测、Express 代码、前端 Axios 拦截器,以及那些将正常的登录功能变成安全事故的具体错误。文章很长,因为身份验证正是那种“缺失的 10% 往往会让你栽跟头”的领域。
What the standard tutorial leaves out
标准教程遗漏了什么
Nearly every “JWT authentication in Node.js” post ends in the same place: sign a token, put it in localStorage, send a Bearer header. That gets you a demo. Four things stand between that and production. 几乎每一篇“Node.js 中的 JWT 身份验证”文章都止步于此:签发令牌、存入 localStorage、发送 Bearer 请求头。这只能让你完成一个演示程序。而要将其投入生产环境,还面临四个问题。
-
localStorage is readable by any JavaScript on the page. That includes the analytics snippet you added last week, the npm package that got compromised upstream, and any XSS hole in your own code. One call to localStorage.getItem(‘token’) and an attacker holds a working credential they can replay from their own machine. You can’t detect it and you can’t stop it.
-
localStorage 可被页面上的任何 JavaScript 读取。 这包括你上周添加的分析脚本、上游被篡改的 npm 包,以及你自己代码中的任何 XSS 漏洞。只需调用一次
localStorage.getItem('token'),攻击者就能获取有效的凭证,并在他们自己的机器上重放。你无法检测也无法阻止。 -
There is no revocation. The appeal of JWTs is stateless verification: the server checks a signature and trusts the payload without touching the database. The price is that you can’t take a token back. Ban a user and they stay logged in. Reset a password and the thief stays logged in.
-
无法撤销。 JWT 的吸引力在于无状态验证:服务器检查签名并信任载荷,无需查询数据库。代价是你无法收回令牌。封禁用户,他们依然在线;重置密码,窃贼依然在线。
-
Long expiry makes both of those catastrophic. Developers reach for a long TTL because nobody wants to be logged out every fifteen minutes. But long expiry is exactly what makes a stolen token worth stealing. You end up choosing between bad UX and a bad breach.
-
长过期时间让上述问题变得灾难性。 开发者倾向于设置较长的有效期,因为没人想每 15 分钟就被强制下线。但长有效期正是让被盗令牌具有价值的原因。最终你只能在糟糕的用户体验和严重的安全漏洞之间做选择。
-
The payload isn’t encrypted. base64url is encoding. Paste any JWT into jwt.io and read it. I’ve seen internal role hierarchies, email addresses, and on one occasion a live database connection string sitting in a token payload.
-
载荷未加密。 base64url 只是编码。把任何 JWT 粘贴到 jwt.io 就能读取。我见过内部角色层级、电子邮件地址,甚至有一次在令牌载荷里看到了实时数据库连接字符串。
One architecture fixes all four: a very short-lived access token held in memory, plus a long-lived refresh token stored in an httpOnly cookie and tracked in your database. 有一种架构可以解决这四个问题:在内存中保存一个极短生命周期的访问令牌,并在 httpOnly Cookie 中存储一个长生命周期的刷新令牌,同时在数据库中进行跟踪。
The architecture
架构设计
| Access Token (JWT, 15 min) | Refresh Token (opaque, 30 days) | |
|---|---|---|
| Held in | JS memory | httpOnly cookie |
| Sent as | Authorization: Bearer … | Sent automatically only to /auth/* |
| Verified by | Signature (No DB call. Fast.) | Looked up in DB, hashed, rotated on every use |
| 访问令牌 (JWT, 15 分钟) | 刷新令牌 (不透明字符串, 30 天) | |
|---|---|---|
| 存储位置 | JS 内存 | httpOnly Cookie |
| 发送方式 | Authorization: Bearer … | 仅自动发送至 /auth/* |
| 验证方式 | 签名验证 (无需查库,速度快) | 数据库查询、哈希比对,每次使用后轮换 |
Two tokens doing two very different jobs. The access token is a real JWT. Short-lived, verified purely by signature, never touches the database. That’s what keeps your API fast. If it leaks, your exposure is fifteen minutes. 两个令牌各司其职。访问令牌是标准的 JWT,生命周期短,仅通过签名验证,从不触碰数据库,这保证了 API 的高性能。如果它泄露,你的风险窗口仅为 15 分钟。
The refresh token is deliberately not a JWT. It’s 64 random bytes. It lives in an httpOnly cookie so JavaScript can’t read it, it’s stored hashed so a database leak doesn’t hand over live sessions, and every time it’s used it gets replaced. That last part, rotation, is what gives you theft detection. It’s also the piece almost nobody implements, and it’s the heart of Step 6. 刷新令牌特意不使用 JWT,而是 64 字节的随机字符串。它存放在 httpOnly Cookie 中,JavaScript 无法读取;它以哈希形式存储,即使数据库泄露也不会直接暴露活跃会话;且每次使用后都会被替换。最后一点——轮换,是实现盗窃检测的关键。这也是几乎没人实现的部分,它是第 6 步的核心。
Step 1: Setup and real secrets
第 1 步:设置与真正的密钥
mkdir jwt-auth-api && cd jwt-auth-api
npm init -y
npm install express jsonwebtoken bcrypt cookie-parser pg dotenv
npm install helmet express-rate-limit cors
npm install -D nodemon
Add “type”: “module” to package.json so ESM imports work.
在 package.json 中添加 "type": "module" 以支持 ESM 导入。
Now the thirty-second step people skip, which is how signing secrets end up being the literal string “secret”. For HS256 you want at least 256 bits of randomness: 现在是人们常跳过的 30 秒步骤,这正是为什么很多签名密钥最终变成了字符串 “secret” 的原因。对于 HS256,你需要至少 256 位的随机性:
node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
Run it twice. You need two secrets and they must not be the same value. 运行两次。你需要两个密钥,且它们的值不能相同。
# .env
NODE_ENV=development
PORT=5000
DATABASE_URL=postgres://user:pass@localhost:5432/myapp
JWT_ACCESS_SECRET=<first generated value>
JWT_REFRESH_PEPPER=<second generated value>
TOKEN_ISSUER=api.your-domain.com
TOKEN_AUDIENCE=your-domain.com
COOKIE_DOMAIN=.your-domain.com
If you’ve read my post on environment variables in Vite, the rules here are stricter. These values are backend-only. Never prefix them with VITE_, never let them reach a browser bundle, and put .env in .gitignore before you write a line of code.
如果你读过我关于 Vite 环境变量的文章,这里的规则更严格。这些值仅限后端使用。永远不要给它们加上 VITE_ 前缀,永远不要让它们进入浏览器打包文件,并在写第一行代码前就把 .env 加入 .gitignore。
A small config module means a missing variable fails at boot rather than at 3am: 一个小型的配置模块可以确保缺失变量时在启动阶段就报错,而不是等到凌晨 3 点才出问题:
// src/config/env.js
import 'dotenv/config';
function required(name) {
const value = process.env[name];
if (!value) throw new Error(`Missing required env variable: ${name}`);
return value;
}
export const config = {
isProd: process.env.NODE_ENV === 'production',
port: process.env.PORT || 5000,
accessSecret: required('JWT_ACCESS_SECRET'),
refreshPepper: required('JWT_REFRESH_PEPPER'),
issuer: required('TOKEN_ISSUER'),
audience: required('TOKEN_AUDIENCE'),
cookieDomain: process.env.COOKIE_DOMAIN,
accessTtl: '15m',
refreshTtlDays: 30,
};
Step 2: Signing and verifying tokens
第 2 步:签发与验证令牌
// src/utils/tokens.js
import jwt from 'jsonwebtoken';
import crypto from 'node:crypto';
import { config } from '../config/env.js';
export function signAccessToken(user) {
return jwt.sign(
{ sub: String(user.id), role: user.role }, // Nothing sensitive. This payload is readable by anyone.
// ... (后续代码省略)
// src/utils/tokens.js
import jwt from 'jsonwebtoken';
import crypto from 'node:crypto';
import { config } from '../config/env.js';
export function signAccessToken(user) {
return jwt.sign(
{ sub: String(user.id), role: user.role }, // 不要包含敏感信息,此载荷任何人可见。
// ... (后续代码省略)