JavaScript JWT 解码 atob & jose
直接在浏览器中使用免费的 JWT解码工具,无需安装。
在线试用 JWT解码工具 →在我构建的每个身份验证流程中,最终都会遇到同一个问题:你手里有一个存在于 Cookie、请求头或 OAuth 回调 URL 中的 JWT,需要读取其中的内容。JavaScript JWT 解码器 不需要任何 npm 包。Token 的 header 和 payload 只是 Base64url 编码的 JSON,浏览器和 Node.js 都内置了解码所需的一切工具。本指南涵盖 JWT 完整的 JavaScript 文本解码流程: 拆分 token、将 base64url 规范化为标准 Base64、使用 atob() 和 TextDecoder 正确处理 UTF-8、Node.js 的 Buffer.from(), 使用 jose 进行签名验证,以及开发者每天都会踩到的常见错误。如需快速一次性检查,可以直接使用 在线 JWT 解码工具。 所有示例均针对 ES2020+ 和 Node.js 18+。
- ✓以 "." 分割 JWT——索引 0 是 header,索引 1 是 payload,索引 2 是签名。
- ✓atob() 解码 Base64 但返回 Latin-1,而非 UTF-8。对非 ASCII 声明请使用 TextDecoder 或 Buffer.from()。
- ✓Buffer.from(segment, "base64url") 在 Node.js 中原生处理 base64url——无需手动替换字符。
- ✓解码不等于验证。在未服务端校验签名的情况下,永远不要信任解码后的 JWT 声明。
- ✓jose 库两者兼顾:一次调用即可验证 HS256/RS256/ES256 签名并返回解码后的 payload。
什么是 JWT 解码?
JSON Web Token 由三个以点号分隔的 Base64url 编码段组成。第一段是 header,第二段是 payload(你真正关心的声明),第三段是加密签名。Header 是一个小型 JSON 对象,描述 token 本身。其最重要的字段是 alg ——签名算法(例如 HS256、 RS256、 ES256)。typ 字段几乎总是 "JWT", 可选的 kid 字段标识用于签名的密钥——当身份提供者轮换密钥并通过 JWKS 端点发布多个公钥时,这一字段至关重要。
Payload 承载声明。RFC 7519 定义了七个注册声明名称: sub (主体——通常是用户 ID), iss (签发者——认证服务器 URL), aud (受众——token 目标 API), iat (签发时间戳), exp (过期时间戳), nbf (生效时间戳),以及 jti (JWT ID——用于防止重放攻击)。所有时间戳均为 Unix 纪元秒数,而非毫秒。签名段是原始二进制—— 带密钥的 HMAC 摘要或非对称数字签名。它与其他段一样经过 Base64url 编码,但其字节不是 JSON, 也没有人类可读的结构。
在实践中,JavaScript 解码 JWT 有三个常见原因。第一是调试:你有一个来自 OAuth 流程或测试环境 的 token,需要确认声明是否与认证服务器应签发的内容匹配。第二是在客户端读取用户声明用于展示—— 直接从 token payload 中显示已登录用户的姓名、头像 URL 或角色标识,无需额外的 API 调用。 第三是在尝试刷新前检查过期情况:如果 exp 在接下来 60 秒内到期,在下一次 API 调用前触发静默刷新,而不是等待 401 响应。
解码不会检查 token 是否有效或是否被篡改。这是一个称为验证的独立操作,需要 HMAC 密钥或 RSA/ECDSA 公钥。任何人都可以解码 JWT,但只有持有正确密钥的人才能验证它。这一区别让许多开发者 困惑,尤其是在构建客户端身份验证流程时——解码后的声明可以展示,但在没有经过验证的后端检查之前, 绝不能用于授权决策。
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTYxMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
// Header
{ "alg": "HS256" }
// Payload
{
"sub": "usr_921f",
"role": "admin",
"iat": 1711610000
}atob() + TextDecoder — 浏览器原生 JWT 解码
浏览器原生的 JWT 解码流程分四个步骤。第一步,以 "." 分割 token 字符串,获取三段内容。第二步,通过将 - 替换为 +、 将 _ 替换为 /, 并补充 = 字符直到长度为 4 的倍数,来规范化 base64url 段。第三步,调用 atob() 将 Base64 解码为二进制字符串。第四步,使用 TextDecoder 将二进制字符串转换为正确的 UTF-8。最后一步非常关键,因为 atob() 返回的是 Latin-1。多字节字符——emoji、CJK 文字、超出 Latin-1 范围的重音字符—— 如果没有经过 JavaScript 文本解码器这一步骤,都会显示为乱码。
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTYxMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
function decodeJwtPayload(jwt) {
const base64Url = jwt.split(".")[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, "=");
const binary = atob(padded);
const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
const json = new TextDecoder("utf-8").decode(bytes);
return JSON.parse(json);
}
console.log(decodeJwtPayload(token));
// { sub: "usr_921f", role: "admin", iat: 1711610000 }补充填充这一步骤很容易被忽略。JWT 会从其 Base64url 段中去掉末尾的 = 字符,因为 JWT 规范(RFC 7515)定义的 base64url 不带填充。但 atob() 在某些浏览器引擎中,如果输入长度不能被 4 整除,会抛出 InvalidCharacterError。 使用 padEnd() 防御性地补充填充,可以在所有环境中避免这个边界情况。以下是一个可复用的版本, 可将 header 和 payload 分别解码为独立对象:
function decodeBase64Url(segment) {
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, "=");
const binary = atob(padded);
const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
return new TextDecoder("utf-8").decode(bytes);
}
function decodeJwt(token) {
const [headerB64, payloadB64] = token.split(".");
return {
header: JSON.parse(decodeBase64Url(headerB64)),
payload: JSON.parse(decodeBase64Url(payloadB64)),
};
}
const { header, payload } = decodeJwt(token);
console.log("Algorithm:", header.alg); // "HS256"
console.log("Subject:", payload.sub); // "usr_921f"
console.log("Role:", payload.role); // "admin"有了这两个函数后,建议将它们放在一个共享工具模块中,而不是在各文件中复制粘贴逻辑。 一个带有类型化返回结构的 src/lib/jwt.ts 或 utils/jwt-decode.ts 文件,能让整个代码库的意图更加清晰。在 TypeScript 中,可以将返回类型标注为 { header: JwtHeader; payload: JwtPayload }, 其中 JwtHeader 包含 alg、 typ 和可选的 kid, 而 JwtPayload 则扩展 RFC 7519 注册声明并带有自定义声明的索引签名。 集中管理解码逻辑意味着以后需要添加错误处理(捕获格式错误的段)或遥测 (记录解码失败)时,只需更新一处。
TextDecoder 步骤是使这个流程对非 ASCII 声明安全的关键。没有它, atob() 返回的是 Latin-1 字符串,多字节 UTF-8 序列会被拆分到多个字符中。你将看到乱码而非 emoji 或 CJK 文字。 在 atob() 之后,始终通过 new TextDecoder("utf-8") 处理。解码包含多字节字符的 UTF-8 JWT 声明
JWT payload 是经 base64url 编码的 UTF-8 JSON。大多数 payload 只包含 ASCII 字段, 如用户 ID 和时间戳,因此开发者往往不会注意到 atob() 返回的是 Latin-1 而非 UTF-8。一旦某个声明包含 emoji、中文字符、西里尔字母, 或任何码点高于 U+00FF 的字符,问题就会暴露。JavaScript 解码 UTF-8 的模式 需要先将二进制字符串转换为字节数组,再通过 TextDecoder 处理。
// 模拟包含 emoji 和中文字符的 JWT payload
const payloadObj = {
sub: "usr_e821",
display_name: "张伟",
team: "Platform 🚀",
region: "cn-north-1"
};
// 编码:对象 → JSON → UTF-8 字节 → base64url
const jsonStr = JSON.stringify(payloadObj);
const utf8Bytes = new TextEncoder().encode(jsonStr);
const base64 = btoa(String.fromCharCode(...utf8Bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// 解码:base64url → base64 → 二进制字符串 → 字节 → UTF-8 字符串
const base64Std = base64.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64Std);
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
const decoded = new TextDecoder("utf-8").decode(bytes);
const result = JSON.parse(decoded);
console.log(result.display_name); // "张伟" — 正确
console.log(result.team); // "Platform 🚀" — 正确在旧代码库中你会看到一种使用 decodeURIComponent 结合百分号编码技巧的旧版回退方案。这种 JavaScript decodeURIComponent 方法之所以有效, 是因为它将每个字节重新编码为百分号十六进制对,然后 decodeURIComponent 重新组装多字节 UTF-8 序列:
function decodeBase64UrlLegacy(segment) {
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64);
// 将每个字符转换为 %XX 十六进制,然后 decodeURIComponent 重组 UTF-8
const utf8 = decodeURIComponent(
binary.split("").map(c =>
"%" + c.charCodeAt(0).toString(16).padStart(2, "0")
).join("")
);
return utf8;
}
// 无需 TextDecoder 即可处理非 ASCII 声明
const payload = decodeBase64UrlLegacy(token.split(".")[1]);
console.log(JSON.parse(payload));decodeURIComponent(escape(atob(segment))) 的写法。escape() 函数已被弃用且不符合标准。请将其替换为上方展示的 TextDecoder 方案。JavaScript unescape 解码器模式 存在同样的问题:unescape() 也已被弃用。 这两个函数可能在未来的 JavaScript 引擎中被移除。JWT 解码流程 — 步骤参考
浏览器原生 JWT 解码流程的每个步骤,包括所使用的 JavaScript API 及其产出:
Node.js 等效方案将步骤 2 到 4 合并为一次调用: Buffer.from(segment, "base64url").toString("utf-8")。"base64url" 编码选项在内部自动处理字符集转换和填充。
Buffer.from() — Node.js 的 JWT 字符串解码器
Node.js 有一条更简单的路径。 Buffer 类直接接受 "base64url" 编码,因此可以跳过手动字符替换和填充计算。这是服务端代码的 JavaScript 字符串解码器路径。 一行代码即可将 JWT 段转换为 UTF-8 字符串,且无需任何额外步骤即可正确处理多字节字符。
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsIm9yZyI6ImFjbWUtY29ycCIsInJvbGUiOiJiaWxsaW5nIiwiaWF0IjoxNzExNjEwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
function decodeJwt(jwt) {
const segments = jwt.split(".");
return {
header: JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8")),
payload: JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8")),
};
}
const { header, payload } = decodeJwt(token);
console.log(header);
// { alg: "HS256" }
console.log(payload);
// { sub: "usr_921f", org: "acme-corp", role: "billing", iat: 1711610000 }这是我在每个 Node.js 项目中都会使用的方案。它更简洁、更快,且已内置 UTF-8 正确处理。 无需 TextDecoder, 无需字符替换,无需填充计算。 Buffer 类是原生支持 base64url 字符集的 JavaScript 字符串解码器,从根本上消除了一类与字符替换相关的 bug。 如果你的代码需要同时在浏览器和 Node.js 中运行,请查看底部 FAQ 中的同构包装函数, 它会在运行时检测环境并分支处理。
以下是一个更完整的示例,展示如何提取常见 JWT 声明并将时间戳转换为可读日期—— 这是你在中间件和 API 路由处理程序中最常用的模式:
function inspectToken(token) {
const segments = token.split(".");
if (segments.length !== 3) {
throw new Error("Not a valid JWT — expected 3 dot-separated segments");
}
const header = JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8"));
const payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8"));
const inspection = {
algorithm: header.alg,
tokenType: header.typ || "JWT",
subject: payload.sub,
issuer: payload.iss || "(not set)",
audience: payload.aud || "(not set)",
issuedAt: payload.iat ? new Date(payload.iat * 1000).toISOString() : "(not set)",
expiresAt: payload.exp ? new Date(payload.exp * 1000).toISOString() : "(never)",
isExpired: payload.exp ? payload.exp < Math.floor(Date.now() / 1000) : false,
customClaims: Object.keys(payload).filter(
k => !["sub", "iss", "aud", "iat", "exp", "nbf", "jti"].includes(k)
),
};
return inspection;
}
console.log(inspectToken(process.env.ACCESS_TOKEN));
// {
// algorithm: "RS256",
// tokenType: "JWT",
// subject: "usr_921f",
// issuer: "https://auth.internal",
// audience: "billing-api",
// issuedAt: "2026-03-10T14:00:00.000Z",
// expiresAt: "2026-03-10T15:00:00.000Z",
// isExpired: true,
// customClaims: ["role", "scope", "org"]
// }在生产 Node.js 服务中, Buffer.from() 解码模式在三个反复出现的场景中非常有用。第一是请求日志中间件:解码传入的 Authorization 头,将 userId 和 org 附加到每条结构化日志条目,无需额外的认证服务器网络请求。第二是调试:在开发过程中将解码后的 token 声明打印到控制台,在编写测试断言前确认签发了正确的作用域。第三是 API 网关中的主动 token 刷新。 与其将 token 转发到上游、让下游服务在 token 过期时返回 401,不如在边缘处解码 token, 读取 exp 声明,并在过期时间在接下来 30 秒内时触发刷新。这消除了一类难以复现且令人沮丧的瞬时认证失败问题。
"base64url" 编码是在 Node.js 15.7.0 中添加的。如果你仍在使用 Node.js 14 或更早版本, 请回退到 Buffer.from(segment.replace(/-/g, "+").replace(/_/g, "/"), "base64"), 效果相同,但需要手动进行字符替换。从文件和 API 响应中解码 JWT
有两个场景反复出现。第一是从本地文件读取 JWT:开发过程中保存的 token、测试固件, 或事故期间用于事后分析而转储的文件。第二是从 HTTP 响应中提取 JWT,通常是 OAuth token 响应体中的 access_token 字段或 Authorization 头。两种情况都需要错误处理,因为格式错误的 token、截断的文件和网络错误是日常工作的一部分。 上周有效的 token 可能因复制粘贴而带有末尾空白或换行符。如果认证服务器返回了错误页面, 响应体可能是 HTML 而非 JSON。
从文件读取 JWT(Node.js)
import { readFileSync } from "node:fs";
function decodeJwtFromFile(filePath) {
const raw = readFileSync(filePath, "utf-8").trim();
const segments = raw.split(".");
if (segments.length !== 3) {
throw new Error(`Invalid JWT: expected 3 segments, got ${segments.length}`);
}
try {
return {
header: JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8")),
payload: JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8")),
};
} catch (err) {
throw new Error(`Failed to decode JWT from ${filePath}: ${err.message}`);
}
}
try {
const { header, payload } = decodeJwtFromFile("./test-fixtures/access-token.txt");
console.log("Algorithm:", header.alg);
console.log("Expires:", new Date(payload.exp * 1000).toISOString());
} catch (err) {
console.error(err.message);
}从 API 响应中提取 JWT(fetch)
async function fetchAndDecodeToken(loginUrl, credentials) {
const response = await fetch(loginUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new Error(`Login failed: ${response.status} ${response.statusText}`);
}
const { access_token } = await response.json();
if (!access_token || access_token.split(".").length !== 3) {
throw new Error("Response does not contain a valid JWT");
}
const payload = access_token.split(".")[1];
const json = Buffer.from(payload, "base64url").toString("utf-8");
return JSON.parse(json);
}
// 用法
try {
const claims = await fetchAndDecodeToken(
"https://auth.internal/oauth/token",
{ username: "deploy-bot", password: process.env.DEPLOY_TOKEN }
);
console.log("Token subject:", claims.sub);
console.log("Token scopes:", claims.scope);
console.log("Expires at:", new Date(claims.exp * 1000).toISOString());
} catch (err) {
console.error("Token decode error:", err.message);
}命令行 JWT 解码
有时你只想从终端快速查看一个 token,而不想编写脚本。Node.js 在大多数开发机器上都有安装, 因此单行命令非常方便。 jq 负责美化输出。
# 使用 Node.js 单行命令解码 JWT payload
echo "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiJ9.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" \
| cut -d. -f2 \
| node -e "process.stdin.on('data', d => console.log(JSON.parse(Buffer.from(d.toString().trim(), 'base64url').toString('utf-8'))))"
# 通过管道传给 jq 美化输出
echo "$JWT_TOKEN" | cut -d. -f2 \
| node -e "process.stdin.on('data', d => process.stdout.write(Buffer.from(d.toString().trim(), 'base64url').toString('utf-8')))" \
| jq .
# 同时解码 header 和 payload
echo "$JWT_TOKEN" | node -e "
process.stdin.on('data', d => {
const parts = d.toString().trim().split('.');
console.log('Header:', JSON.parse(Buffer.from(parts[0], 'base64url').toString()));
console.log('Payload:', JSON.parse(Buffer.from(parts[1], 'base64url').toString()));
});
"如果你不想用 Node.js,可以通过 tr 修复 base64url 字符后,将段内容通过管道传给 base64 -d:
# 纯 bash:不使用 Node.js 解码 JWT payload echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq . # macOS 变体(使用 base64 -D 而非 -d) echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -D 2>/dev/null | jq .
如果不想使用任何终端,直接将你的 token 粘贴到 ToolDeck JWT 解码工具, 即可获得三段内容的并排分解,带有颜色标注的声明标签和过期状态显示。
jose — 一个库同时完成验证和解码
对于生产环境的认证中间件,你需要的是签名验证,而不仅仅是解码。 jose 库是最佳选择。它同时支持 Node.js 和浏览器(通过 Web Crypto API), 支持 HS256、RS256、ES256、EdDSA 和 JWE(加密 token),且没有原生依赖。 使用 npm install jose 安装。
import * as jose from "jose";
const secret = new TextEncoder().encode("k8s-webhook-signing-secret-2026");
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInNjb3BlIjoiYmlsbGluZzpyZWFkIiwiaWF0IjoxNzExNjEwMDAwLCJleHAiOjE3MTE2MTM2MDB9.abc123";
try {
const { payload, protectedHeader } = await jose.jwtVerify(token, secret);
console.log("Algorithm:", protectedHeader.alg); // "HS256"
console.log("Subject:", payload.sub); // "usr_921f"
console.log("Scope:", payload.scope); // "billing:read"
} catch (err) {
if (err.code === "ERR_JWT_EXPIRED") {
console.error("Token expired at:", err.payload.exp);
} else {
console.error("Verification failed:", err.message);
}
}import * as jose from "jose";
// 从身份提供者获取公钥集
const jwks = jose.createRemoteJWKSet(
new URL("https://auth.internal/.well-known/jwks.json")
);
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "Missing token" });
}
try {
const { payload } = await jose.jwtVerify(token, jwks, {
issuer: "https://auth.internal",
audience: "billing-api",
});
// payload.sub、payload.scope 等均已通过验证
req.userId = payload.sub;
} catch (err) {
return res.status(401).json({ error: "Invalid token" });
}在 jose 和旧版 jsonwebtoken 包之间做选择时,关键区别在于运行时范围。jsonwebtoken 仅适用于 Node.js——它依赖内置的 crypto 模块,无法打包到浏览器中。jose 完全同构:它使用 Web Crypto API,该 API 在所有现代浏览器、Node.js 16+、Deno、Bun 和 Cloudflare Workers 中均可用。如果你的认证逻辑存在于 Next.js 中间件文件(运行于 Edge Runtime)、 Cloudflare Worker,或由服务端和客户端代码共同引入的共享工具中, jose 是正确的选择,因为它没有原生依赖,无需构建步骤即可安装。jsonwebtoken 对于纯 Node.js 服务端应用仍然合理,前提是你需要其更丰富的签名辅助工具生态, 且不打算在边缘环境中运行代码。在 2026 年的全新项目中, 除非有特定原因偏向旧版 API,否则默认选择 jose。
如果你只需要解码而不需要验证,jose 提供了 jose.decodeJwt(token), 返回 payload;以及 jose.decodeProtectedHeader(token) 用于获取 header。这些是内部完成 Base64url 解码的便捷函数。但使用 jose 的全部意义在于, 你很少应该只解码而不验证。如果你在客户端只需要从 token 声明中显示用户自己的显示名称或头像 URL, 仅解码是没问题的。在服务端,始终进行验证。我见过生产系统在未检查签名的情况下解码 JWT 声明用于 访问控制决策,这对任何了解 JWT 格式的攻击者而言都是一扇敞开的大门。
import * as jose from "jose";
// 仅解码:无需密钥,无需验证
const payload = jose.decodeJwt(token);
console.log(payload.sub); // "usr_921f"
console.log(payload.scope); // "billing:read"
const header = jose.decodeProtectedHeader(token);
console.log(header.alg); // "HS256"
console.log(header.typ); // "JWT"
// 无需验证即可检查过期(客户端展示)
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
console.log("Token has expired — redirect to login");
}终端输出与语法高亮
在 Node.js CLI 工具中调试 JWT token 或处理故障时,带颜色的输出会带来显著差异。 chalk 库搭配 JSON.stringify 即可实现。使用 npm install chalk 安装。
import chalk from "chalk";
function printJwt(token) {
const segments = token.split(".");
if (segments.length !== 3) {
console.error(chalk.red("Invalid JWT: expected 3 segments"));
return;
}
const header = JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8"));
const payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8"));
console.log(chalk.bold.cyan("\n=== JWT Header ==="));
console.log(chalk.gray(JSON.stringify(header, null, 2)));
console.log(chalk.bold.green("\n=== JWT Payload ==="));
console.log(chalk.gray(JSON.stringify(payload, null, 2)));
// 高亮显示过期状态
if (payload.exp) {
const expiresAt = new Date(payload.exp * 1000);
const isExpired = expiresAt < new Date();
console.log(
chalk.bold("\nExpires:"),
isExpired
? chalk.red(`EXPIRED at ${expiresAt.toISOString()}`)
: chalk.green(`Valid until ${expiresAt.toISOString()}`)
);
}
console.log(chalk.dim("\nSignature: " + segments[2].substring(0, 20) + "..."));
}
printJwt(process.argv[2]);
// 运行:node jwt-debug.mjs "eyJhbGci..."从大型日志文件中处理 JWT
现代 API 基础设施以 NDJSON 格式发出结构化访问日志——每行一个 JSON 对象,包含请求路径、 响应状态、延迟以及解码或原始的 Authorization 头。在高流量服务中,这些文件增长迅速:每分钟处理 10,000 个请求的网关每天产生超过 1400 万条 日志记录。安全和合规场景经常需要事后扫描这些文件——识别被泄露的服务账号发出的每个请求(事后分析)、 确认特定用户的 token 在数据访问窗口之前已过期(合规审计),或提取在维护窗口期间访问敏感端点的 完整主体集合。由于单个日志文件可能超过数 GB,用 readFileSync 将其加载到内存中不可行。Node.js readline 流逐行处理文件,内存开销恒定, 使得在普通开发笔记本上扫描任意大小的日志成为可能。
单个 JWT 不会遇到"文件太大无法加载到内存"的问题,因为单个 token 很少超过几千字节。 真正出现的场景是扫描大型访问日志或审计记录以查找 JWT token,对每个 token 进行解码, 并提取特定声明。Node.js 流可以在不加载整个文件的情况下处理这一问题。
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
async function scanLogsForExpiredTokens(logPath) {
const fileStream = createReadStream(logPath, { encoding: "utf-8" });
const rl = createInterface({ input: fileStream, crlfDelay: Infinity });
let lineCount = 0;
let expiredCount = 0;
const nowSeconds = Math.floor(Date.now() / 1000);
for await (const line of rl) {
lineCount++;
try {
const entry = JSON.parse(line);
if (!entry.authorization_token) continue;
const segments = entry.authorization_token.split(".");
if (segments.length !== 3) continue;
const payload = JSON.parse(
Buffer.from(segments[1], "base64url").toString("utf-8")
);
if (payload.exp && payload.exp < nowSeconds) {
expiredCount++;
const expDate = new Date(payload.exp * 1000).toISOString();
console.log("Line " + lineCount + ": expired token for " + payload.sub + ", exp=" + expDate);
}
} catch {
// 跳过格式错误的行
}
}
console.log(`\nScanned ${lineCount} lines, found ${expiredCount} expired tokens`);
}
scanLogsForExpiredTokens("./logs/api-access-2026-03.ndjson");import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
async function extractUniqueSubjects(logPath) {
const rl = createInterface({
input: createReadStream(logPath, { encoding: "utf-8" }),
crlfDelay: Infinity,
});
const subjects = new Set();
const jwtRegex = /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
for await (const line of rl) {
const matches = line.match(jwtRegex);
if (!matches) continue;
for (const token of matches) {
try {
const payload = JSON.parse(
Buffer.from(token.split(".")[1], "base64url").toString("utf-8")
);
if (payload.sub) subjects.add(payload.sub);
} catch {
// 不是有效的 JWT
}
}
}
console.log(`Found ${subjects.size} unique subjects:`);
for (const sub of subjects) console.log(` ${sub}`);
}
extractUniqueSubjects("./logs/gateway-2026-03.log");readFileSync 加载 500 MB 的 NDJSON 文件 会占满内存并触发 GC 停顿。readline 方式逐行处理,内存占用恒定。常见错误
问题: atob() 返回 Latin-1 字符串。多字节 UTF-8 字符(emoji、CJK 字符、重音字符)会被拆分到多个字符中,输出乱码。
修复: 将 atob() 的输出转换为 Uint8Array,然后通过 new TextDecoder('utf-8') 处理。
// 在非 ASCII payload 声明上失效
const payload = JSON.parse(atob(token.split(".")[1]));
// display_name 显示为 "ç¼ ä¼" 而不是 "张伟"const binary = atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"));
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
const payload = JSON.parse(new TextDecoder("utf-8").decode(bytes));
// display_name 正确显示为 "张伟"问题: atob() 抛出 "InvalidCharacterError",因为 base64url 使用 - 和 _ 而不是 + 和 /。
修复: 在调用 atob() 前将 - 替换为 + 并将 _ 替换为 /。Node.js 的 Buffer.from() 使用 'base64url' 时会自动处理。
// 抛出:InvalidCharacterError: String contains an invalid character
const payload = atob(token.split(".")[1]);const segment = token.split(".")[1];
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const payload = atob(base64); // 现在可以正常工作问题: 任何人都可以创建带有任意 payload 的 JWT。解码只是读取数据——并不能证明 token 是由你的认证服务器签发的。
修复: 在服务端,始终使用 jose.jwtVerify() 或 jsonwebtoken.verify() 验证签名。仅解码可接受用于客户端展示用户声明。
// 危险:已解码但未验证
const claims = JSON.parse(atob(token.split(".")[1]));
if (claims.role === "admin") {
grantAdminAccess(); // 攻击者可以伪造此声明
}import * as jose from "jose";
const { payload } = await jose.jwtVerify(token, secretKey);
if (payload.role === "admin") {
grantAdminAccess(); // 安全——签名已验证
}问题: JWT exp 是以秒为单位的纪元时间戳,而 Date.now() 返回毫秒。比较结果将始终显示 token 有效,因为毫秒时间戳是秒的 1000 倍。
修复: 在与 exp 比较之前,将 Date.now() 除以 1000 并取整。
// Bug:Date.now() 是毫秒,exp 是秒
if (payload.exp > Date.now()) {
console.log("Token is valid"); // 始终为 true——错误!
}const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp > nowSeconds) {
console.log("Token is valid"); // 正确的比较
}JWT 解码方法 — 快速对比
在浏览器端只需向用户展示声明时,使用 atob() + TextDecoder。 在 Node.js 脚本和 CLI 工具中使用 Buffer.from()。 一旦需要验证签名(即任何服务端认证中间件),请使用 jose。 jwt-decode 包是在浏览器中进行仅解码操作时的轻量级替代方案,提供单函数 API。如需在不编写代码的情况下 快速可视化检查,请将 token 粘贴到 JWT 解码工具。
常见问题解答
如何在不使用库的情况下用 JavaScript 解码 JWT token?
以 "." 分割 token,取第二段(payload),通过将 - 替换为 + 以及 _ 替换为 / 来规范化 base64url 编码,补齐 = 字符,然后调用 atob() 再通过 TextDecoder 获得 UTF-8 JSON 字符串。将结果传给 JSON.parse() 即可得到声明对象,无需任何 npm 包。此方法适用于所有现代浏览器及 Node.js 18+。如果还需要读取 header,对第一段执行相同步骤即可。请注意,这只是获取原始数据,不包含签名验证——除非在服务端验证签名,否则结果仅供展示。
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const payload = token.split(".")[1];
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = atob(base64);
const claims = JSON.parse(json);
console.log(claims);
// { sub: "usr_921f", role: "admin" }atob() 和 Buffer.from() 在 JWT 解码上有什么区别?
atob() 是浏览器 API,将标准 Base64 解码为 Latin-1 二进制字符串。它不直接支持 base64url 编码,因此需要先替换 - 和 _ 字符。Buffer.from(segment, "base64url") 是 Node.js API,原生支持 base64url 字符集并返回可调用 .toString("utf-8") 的 Buffer。在浏览器中使用 atob(),在 Node.js 中使用 Buffer.from()。第三种方案——虽较慢但在历史上很常见——是 decodeURIComponent 百分号编码技巧,但某些旧代码片段中该模式依赖已废弃的 escape() 函数,新代码中应避免使用。对于同时在两种环境中运行的同构代码,可以检查 typeof Buffer !== "undefined" 并分支处理。
// 浏览器
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
// Node.js
const json2 = Buffer.from(payload, "base64url").toString("utf-8");为什么 atob() 在处理非 ASCII 的 JWT 声明时会出错?
atob() 返回 Latin-1 字符串,其中每个字符对应一个字节。多字节 UTF-8 序列(emoji、CJK 字符、超出 Latin-1 范围的重音字母)会被拆分到多个字符中,导致输出乱码。修复方法是先将二进制字符串转换为 Uint8Array,再传给 new TextDecoder("utf-8").decode()。TextDecoder API 能正确重组多字节序列。这个问题在开发阶段很容易被忽略,因为大多数 JWT payload 只包含 ASCII 用户 ID、时间戳和角色名——只有当某个声明包含非 ASCII 显示名称或本地化字符串时才会暴露。即使当前 payload 只含 ASCII,新代码也应始终使用 TextDecoder 路径,因为声明可能随应用演进而变化。
// 错误:atob 返回 Latin-1,多字节字符会乱码
const broken = atob(base64); // "ð\x9F\x8E\x89" 而不是 emoji
// 正确:转换为字节数组,再用 TextDecoder
const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
const fixed = new TextDecoder("utf-8").decode(bytes);能在 JavaScript 中验证 JWT 签名吗?
解码和验证是两种不同的操作。解码只是读取 payload,而 payload 并未加密。验证则是根据密钥(HMAC)或公钥(RSA/ECDSA)检查签名。jose 库通过浏览器的 Web Crypto API 以及 Node.js 均支持这两种操作。jsonwebtoken 包仅适用于 Node.js。永远不要在未验证签名的情况下信任解码后的声明。在客户端,解码 JWT 以读取用户显示名称或过期时间是可以接受的,但任何访问控制决策——如检查用户是否具有特定角色或权限——必须在验证后的服务端代码中执行。了解 JWT 格式的攻击者可以伪造包含任意声明的 token,客户端检查将直接通过。
import * as jose from "jose";
const secret = new TextEncoder().encode("your-256-bit-secret");
const { payload } = await jose.jwtVerify(token, secret);
console.log(payload.sub); // verified claims如何用 JavaScript 检查 JWT 是否已过期?
解码 payload 并读取 exp 声明,它是以秒为单位的 Unix 时间戳。用 Math.floor(Date.now() / 1000) 与当前时间比较。如果当前时间大于 exp,则 token 已过期。请注意:exp 是秒级时间戳,而非毫秒,因此必须将 Date.now() 除以 1000。实践中,建议留出少量时钟偏差缓冲——检查 token 是否在未来 30 秒内过期,而非严格过去,可以避免这种边界情况:解码时 token 仍然有效,但到下一个下游 API 调用处理时已经过期。同时处理 exp 完全缺失的情况,这意味着 token 永不过期。
function isTokenExpired(token) {
const payload = JSON.parse(
atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
);
const nowSeconds = Math.floor(Date.now() / 1000);
return payload.exp < nowSeconds;
}
console.log(isTokenExpired(myToken)); // true or false如何编写在 Node.js 和浏览器中均可运行的同构 JWT 解码代码?
检查 globalThis.Buffer 是否存在。如果存在,说明处于 Node.js 环境,可使用 Buffer.from(segment, "base64url").toString("utf-8")。如果不存在,说明处于浏览器环境,应使用 atob() 结合 TextDecoder 的方式。将此检查封装在一个 decodeBase64Url 函数中并统一使用。这对工具包、设计系统组件,以及 monorepo 中同时被 Next.js 服务端组件和浏览器 React 组件引入的共享代码尤为重要。将环境检测集中在一处意味着运行时发生变化时只需更新一个地方——例如,当 Deno 完全支持 Buffer 或新的边缘运行时需要不同代码路径时。
function decodeBase64Url(segment) {
if (typeof Buffer !== "undefined") {
return Buffer.from(segment, "base64url").toString("utf-8");
}
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
return new TextDecoder("utf-8").decode(bytes);
}相关工具
Marcus specialises in JavaScript performance, build tooling, and the inner workings of the V8 engine. He has spent years profiling and optimising React applications, working on bundler configurations, and squeezing every millisecond out of critical rendering paths. He writes about Core Web Vitals, JavaScript memory management, and the tools developers reach for when performance really matters.
Sophie is a full-stack developer focused on TypeScript across the entire stack — from React frontends to Express and Fastify backends. She has a particular interest in type-safe API design, runtime validation, and the patterns that make large JavaScript codebases stay manageable. She writes about TypeScript idioms, Node.js internals, and the ever-evolving JavaScript module ecosystem.