JWT vs PASETO v2 vs TECTO:2026 年选择合适的令牌协议
Source: Dev.to
令牌在现代认证流程中随处可见。但并非所有令牌都一样。
在本文中,我们将并排比较三种方法——经典的 JWT (HS256)、更现代的 PASETO v2,以及全新的 TECTO——从安全性、易用性和实际代码示例三个维度进行对比。
快速比较表
| 属性 | JWT (HS256) | PASETO v2 | TECTO |
|---|---|---|---|
| 负载可见? | ✅ 是(base64) | ✅ 是(已签名,未加密) | ❌ 完全加密 |
| 密码算法 | 无(HMAC) | Ed25519(签名)/ XChaCha20(加密) | XChaCha20‑Poly1305 |
| Nonce | 不适用 | 每个令牌24字节 | 每个令牌24字节的CSPRNG |
| 密钥大小 | 可变 | 可变 | 正好256位(强制) |
| 篡改检测 | HMAC签名 | Ed25519 / Poly1305标签 | Poly1305认证标签 |
| 错误细节 | 揭示原因 | 揭示原因 | 通用“无效令牌” |
| 算法混淆攻击 | ⚠️ 是(alg: none问题) | ✅ 否 | ✅ 否 |
| 内置密钥轮换 | ❌ 自行实现 | ❌ 自行实现 | ✅ 原生(令牌中包含kid) |
1️⃣ JWT – 老牌可靠
jsonwebtoken 是 Node.js 中使用最广泛的 token 库。它经受了大量实战考验,拥有庞大的生态系统,入门极其简单。
npm install jsonwebtoken
import jwt from "jsonwebtoken";
const SECRET = "my-secret-key"; // ← This is the problem
// Sign
const token = jwt.sign(
{ userId: 42, role: "admin" },
SECRET,
{ expiresIn: "1h", issuer: "my-app" }
);
console.log(token);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMzYwMCwiaXNzIjoibXktYXBwIn0.SIGNATURE
// Verify
const payload = jwt.verify(token, SECRET) as { userId: number; role: string };
console.log(payload.userId); // 42
Payload 是可读的
// Decode the middle segment of any JWT
const [, payload] = token.split(".");
const decoded = Buffer.from(payload, "base64url").toString("utf-8");
console.log(decoded);
// {"userId":42,"role":"admin","iat":1700000000,"exp":1700003600,"iss":"my-app"}
任何拦截到 token 的人都可以读取其中的 payload——不需要密钥。这本就是设计如此:JWT 是签名的,而不是加密的。许多开发者起初并未意识到这一点。
算法混淆问题
JWT 允许在头部指定使用的算法。这导致了臭名昭著的 alg: none 攻击,攻击者可以通过将算法设为 none 来伪造 token。即使现代库已经阻止了 none,HMAC 与 RSA 混淆攻击在接受来自多个发行者的 token 时仍然是一个真实的风险。
何时使用 JWT 合理
- 公共的、非敏感的 payload(如用户 ID、角色)
- 与需要 JWT 的第三方服务集成(OAuth、OIDC)
- 团队已经拥有 JWT 基础设施
2️⃣ PASETO – 跨平台安全令牌
PASETO 旨在修复 JWT 的致命缺陷。它完全去除了算法灵活性——你只需选择一个版本,就会得到一个固定且精心挑选的算法。没有 alg: none,也没有混淆攻击。
npm install paseto
import { V2 } from "paseto";
const key = await V2.generateKey("local");
// ---- Encrypt (v2.local) ----
const token = await V2.encrypt(
{ userId: 42, role: "admin" },
key,
{ expiresIn: "1h", issuer: "my-app" }
);
console.log(token);
// v2.local.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
// Decrypt
const payload = await V2.decrypt(token, key);
console.log(payload.userId); // 42
// ---- Sign (v2.public) ----
const { privateKey, publicKey } = await V2.generateKey("public");
// Sign (payload is visible, like JWT)
const signed = await V2.sign({ userId: 42 }, privateKey, { expiresIn: "1h" });
// Verify
const verified = await V2.verify(signed, publicKey);
亮点
- 无算法混淆 —— 版本号 (
v2) 固定了使用的算法。 v2.local加密 负载(XChaCha20‑Poly1305)。- 干净、现代的异步 API。
- 没有原生的密钥轮换机制 ——
kid不是令牌格式的一部分;你必须自行管理密钥版本。 - 错误信息仍可能泄露失败原因。
- 对密钥不进行熵校验——即使提供弱密钥,库也会悄然接受。
3️⃣ TECTO – 传输加密紧凑令牌对象
TECTO 采用不同的理念:每个令牌始终都是完整加密的。没有“签名‑但可读”模式。
bun add tecto
import {
generateSecureKey,
MemoryKeyStore,
TectoCoder,
InvalidSignatureError,
TokenExpiredError,
assertEntropy,
} from "tecto";
// 1️⃣ 生成一个密码学安全的 256‑bit 密钥
const key = generateSecureKey();
// 2️⃣ 设置密钥存储
const store = new MemoryKeyStore();
store.addKey("my-key-2026", key);
// 3️⃣ 创建编码器
const coder = new TectoCoder(store);
// 4️⃣ 加密
const token = coder.encrypt(
{ userId: 42, role: "admin" },
{ expiresIn: "1h", issuer: "my-app" }
);
console.log(token);
// tecto.v1.my-key-2026.base64url_nonce.base64url_ciphertext
// 5️⃣ 解密
const payload = coder.decrypt(token);
console.log(payload.userId); // 42
令牌格式
tecto.v1...
- kid(密钥 ID)嵌入在令牌本身,实现原生密钥轮换——无需额外的元数据或头部。
// 轮换密钥
store.addKey("key-2026-01", oldKey); // 旧密钥仍可解密旧令牌
store.rotate("key-2026-06", newKey); // 新令牌使用新密钥
store.removeKey("key-2026-01"); // 当不再需要时删除旧密钥
强密钥熵检查
// 这些都会抛出 KeyError
assertEntropy(new Uint8Array(32)); // 全部为零
assertEntropy(new Uint8Array(32).fill(0xaa)); // 重复字节
assertEntropy(new Uint8Array(16)); // 长度错误
// 这是安全的
const goodKey = generateSecureKey(); // 始终高熵,256‑bit
assertEntropy(goodKey); // ✅ 通过
统一错误处理
try {
coder.decrypt(tamperedToken);
} catch (err) {
if (err instanceof InvalidSignatureError) {
// err.message === "Invalid token"
// 你不知道 *为什么* 失败——这正是设计初衷。
// 攻击者无法通过观察错误信息来探测系统。
}
if (err instanceof TokenExpiredError) {
// 令牌已过期——可以安全地告知客户端。
}
}
TECTO 的优势场景
- 需要对每个声明完全保密。
- 原生密钥轮换,无需额外的管道。
- 统一、非信息化的错误消息,以阻止探测攻击。
TL;DR
| Feature | JWT (HS256) | PASETO v2 | TECTO |
|---|---|---|---|
| 负载可读吗? | ✅ (base64) | ✅ (已签名) | ❌ (已加密) |
| 算法固定吗? | ❌ (alg 头部) | ✅ (版本固定) | ✅ (内置) |
| 原生密钥轮换? | ❌ | ❌ | ✅ (令牌中包含 kid) |
| 强密钥长度强制? | ❌ | ❌ | ✅ (256 位) |
| 统一错误信息? | ❌ | ❌ | ✅ |
| 最适合… | 公开的、非敏感数据;已有的 JWT 生态系统 | 需要签名令牌且可选加密的现代应用 | 任何必须保密且受益于内置轮换的场景 |
选择符合您安全和运营需求的令牌格式——并且 不要以为“已签名=安全”。加密同样重要。
令牌格式比较
以下是 JWT、PASETO v2 和 TECTO 的快速对比。
三者都可以携带相同的负载,例如 { userId: 42, role: "admin" },但它们在数据保护方式上差异巨大。
具体示例
// 我们想要传输的负载
{ userId: 42, role: "admin" }
JWT(仅签名)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ ← { userId: 42, role: "admin" } 的 base64
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
任何人都可以对中间段使用 atob() 并读取负载——不需要密钥。
PASETO v2.local(加密)
v2.local.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxFULL_CIPHERTEXT
使用 XChaCha20‑Poly1305 加密。没有密钥时,密文是不可读的。
TECTO(加密 + 内置密钥标识)
tecto.v1.my-key-2026.NONCE_BASE64URL.CIPHERTEXT_BASE64URL
使用 XChaCha20‑Poly1305 加密。
kid(my‑key‑2026)仅作为标签可见;没有密钥时负载仍不可读取。
功能矩阵
| 功能 | JWT | PASETO v2 | TECTO |
|---|---|---|---|
| 令牌中的密钥 ID | ❌ Not standard | ❌ Not standard | ✅ Built‑in kid |
| 密钥轮换后旧令牌可解密 | DIY | DIY | ✅ store.rotate() handles it |
| 撤销旧密钥 | DIY | DIY | ✅ store.removeKey() zeroes memory |
| 熵验证 | ❌ No | ❌ No | ✅ assertEntropy() enforced |
| 防篡改 | ✅ HMAC | ❌ Payload readable by anyone | ✅ Authenticated encryption (XChaCha20‑Poly1305) |
| 算法灵活性 | ❌ Algorithm confusion attack surface | ✅ Version pins algorithm | ✅ Version pins algorithm |
| 原生密钥轮换 | ❌ No | ❌ No | ✅ With kid |
| 通用错误(无 oracle 攻击) | ✅ | ✅ | ✅ |
| 时序安全比较 | ✅ | ✅ | ✅ |
| 密钥移除时内存清零 | ✅ | ✅ | ✅ |
| 负载大小限制(防止 DoS) | ✅ | ✅ | ✅ |
| 类型检查的已注册声明 | ✅ | ✅ | ✅ |
何时使用哪种令牌
-
使用 JWT 如果您需要:
- 与 OAuth / OIDC / 现有基础设施兼容
- 负载中不包含敏感数据(仅 ID、角色等)
- 与仅支持 JWT 的第三方服务集成
-
使用 PASETO v2.local 如果您想要:
- 一个经过充分审计、标准化的 加密 令牌
- 跨多语言/平台的互操作性
- 没有内置的本地密钥轮换(您需要自行处理轮换)
-
使用 TECTO 如果您想要:
- 默认加密,且零配置错误的可能性
- 无需额外基础设施的本地密钥轮换
- 一个全新的 TypeScript/Bun 项目
- 深度防御:熵验证、通用错误、时序安全、内存清零、负载大小限制等
TECTO 快速入门
bun add tecto
import { generateSecureKey, MemoryKeyStore, TectoCoder } from "tecto";
// 1️⃣ Create a key store and add a key
const store = new MemoryKeyStore();
store.addKey("v1", generateSecureKey());
// 2️⃣ Build a coder that knows about the store
const coder = new TectoCoder(store);
// 🔐 Encrypt a payload (expires in 1 hour)
const token = coder.encrypt({ userId: 42 }, { expiresIn: "1h" });
// 🔓 Decrypt it back (type‑checked)
const { userId } = coder.decrypt(token);
持久化密钥存储
TECTO 附带了 SQLite、PostgreSQL 和 MariaDB 的适配器——它们都实现了相同的 KeyStoreAdapter 接口,因此切换后端非常简单。
结论
- JWT 将继续成为联合身份验证和 OAuth 流程的事实标准——这对于公开的、非敏感的声明来说是可以接受的。
- PASETO v2.local 为您提供可靠、标准化的加密令牌,但您必须自行管理密钥轮换和熵。
- TECTO 更进一步:内置密钥轮换、强制熵检查、通用错误处理、时序安全比较以及自动内存清零。
最佳的令牌协议是那种你无法错误配置的。
TECTO 为此提供了有力的论据。