JWT 默认只是对 Header 和 Payload 做 Base64Url 编码,令牌一旦被截获,里面的用户 ID、角色权限等信息就完全暴露。如果业务中需要在令牌里携带手机号、身份证号或者内部权限标识,就需要用到 JWE(JSON Web Encryption)。JWE 是 RFC 7516 定义的标准,它把整个负载真正加密成密文,没有密钥的第三方即使拿到令牌也无法解密。这篇文章讲清楚在 Vue 3 项目中如何从零到一工程化地落地 JWE 方案。

一、先理解 JWE 和 JWT 的关系
很多人把 JWT 等同于 JWS(JSON Web Signature),其实 JWT 只是一个统称概念,具体实现有两类:JWS 负责签名防篡改,JWE 负责加密防泄露。一个标准的 JWT 令牌如果是 header.payload.signature 三段式结构,那就是 JWS;如果是五段式结构 header.encryptedKey.iv.ciphertext.tag,那就是 JWE。
JWS 的问题在于 Base64 不是加密,atob() 一行代码就能还原明文。JWE 则采用混合加密体系:用内容加密密钥(CEK)对负载做对称加密(通常选 AES-GCM),再用密钥管理算法把 CEK 加密传递。如果采用 dir 直连模式,双方共享一个对称密钥;如果采用 RSA-OAEP 或 ECDH-ES,则用非对称密钥协商,前端用公钥加密,后端用私钥解密。
需要强调一点:JWE 保护的是机密性,它本身不提供防篡改能力(除非使用 AES-GCM 这种认证加密模式)。在认证授权场景下,后端验证通过后仍然要配合 HTTPS 传输,这是底线,不能因为用了 JWE 就放松。
二、密钥方案选型与 jose 库
前端生态里做 JWE 首选 jose 库,它是纯 JavaScript 实现,支持 Web Crypto API,Vue 3 配合 Vite 引入没有任何障碍。安装很简单:
npm install jose
密钥方案有两种选择。第一种是 dir 模式,前后端共享同一个 256 位对称密钥,实现最简单,性能也最好,但密钥要打包进前端代码,本质上只是混淆而不是保密,适合内部系统或对安全等级要求不极端的场景。
// src/utils/crypto.js
import { EncryptJWT, jwtDecrypt } from 'jose'
// 从环境变量读取 base64 编码的密钥并还原
const secret = new TextEncoder().encode(
window.atob(import.meta.env.VITE_JWE_SECRET)
)
// 加密负载,生成 JWE 令牌
export async function encryptToken(payload) {
return await new EncryptJWT(payload)
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt()
.setIssuer('vue-app')
.setExpirationTime('2h')
.encrypt(secret)
}
// 解密 JWE 令牌
export async function decryptToken(token) {
const { payload } = await jwtDecrypt(token, secret, {
issuer: 'vue-app'
})
return payload
}第二种是 RSA-OAEP-256 非对称模式。后端生成密钥对,公钥通过接口下发给前端,前端用公钥加密,只有持有私钥的后端能解密。这样即使前端代码被完全分析,攻击者也拿不到解密能力。密钥对生成一次后缓存在内存中即可,不必每次请求都拉取:
import { importSPKI, EncryptJWT } from 'jose'
let publicKeyCache = null
async function getPublicKey() {
if (publicKeyCache) return publicKeyCache
const pem = await fetch('/api/auth/public-key').then(r => r.text())
publicKeyCache = await importSPKI(pem, 'RSA-OAEP-256')
return publicKeyCache
}
export async function encryptWithRSA(payload) {
const key = await getPublicKey()
return await new EncryptJWT(payload)
.setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' })
.setIssuedAt()
.setExpirationTime('30m')
.encrypt(key)
}两种方案的取舍:内部管理后台用 dir 足够;面向公网的 C 端应用、令牌里含高敏信息时,建议用非对称模式,代价是令牌体积会大一些(五段式中 encryptedKey 部分更长),加密耗时也略高。
三、axios 拦截器集成与令牌刷新
工程化的关键是把加密逻辑收敛到统一位置,业务代码完全不感知。借助 axios 拦截器,可以在请求发出前自动给负载加密,在响应回来后自动解密刷新令牌。以下是一个可直接落地的封装:
// src/utils/request.js
import axios from 'axios'
import { encryptToken, decryptToken } from './crypto'
const service = axios.create({ baseURL: '/api', timeout: 15000 })
service.interceptors.request.use(async (config) => {
const token = localStorage.getItem('jwe_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 需要加密传输的业务参数
if (config.__encryptPayload) {
config.headers['X-Encrypted-Body'] = await encryptToken(config.data)
config.data = null
}
return config
})
service.interceptors.response.use(async (response) => {
// 后端返回的刷新令牌也是 JWE 格式,解密后重新存储
const newToken = response.headers['x-refreshed-token']
if (newToken) {
localStorage.setItem('jwe_token', newToken)
}
return response
})
export default service这里有一个容易踩的坑:令牌过期时间要和刷新机制对齐。JWE 里设置的 exp 声明后端会校验,前端可以提前在路由守卫里解密判断剩余时间,低于阈值就先调刷新接口,避免出现用户操作到一半被踢出登录的情况:
// src/router/index.js 中使用
import { decryptToken } from '@/utils/crypto'
router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem('jwe_token')
if (!token) return next('/login')
try {
const payload = await decryptToken(token)
const remain = payload.exp * 1000 - Date.now()
if (remain < 5 * 60 * 1000) {
// 剩余不足 5 分钟,静默刷新
const { data } = await service.post('/auth/refresh')
localStorage.setItem('jwe_token', data.token)
}
next()
} catch (e) {
// 解密失败说明令牌非法或被篡改
next('/login')
}
})四、密钥管理与工程化细节
密钥管理是整个方案的重心。使用 dir 模式时,密钥不要明文写进源码,正确做法是放在 Vite 环境变量文件中,并做 Base64 编码处理,同时在 .gitignore 中排除 .env.local。密钥内容可以用 Node 一次性生成 32 字节随机值:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"把输出结果写进 .env.local 的 VITE_JWE_SECRET 变量即可。要清楚认知到,任何进入前端代码的信息都不是绝对保密的,所以对称密钥方案的价值在于防止令牌内容被随意阅读,而不是对抗专业逆向。真正需要高安全等级时,切换到非对称方案才是正解。
另外几点工程细节值得注意:第一,alg 和 enc 头部参数要写死在代码里,不要由外部输入决定,防止算法混淆攻击;第二,生产环境务必配合 CSP 策略限制脚本来源;第三,前后端约定好时钟偏移容忍值,避免分布式部署时因服务器时间不一致导致令牌提前失效;第四,如果项目用了微前端或 iframe 嵌套场景,密钥派生逻辑要收敛到统一的共享包里,避免多个子应用各自维护一份导致轮换不同步。
最后总结一下整体思路:JWE 解决的是令牌内容泄露问题,在 Vue 3 中配合 jose 库可以很低成本地接入,通过 axios 拦截器和路由守卫把加密、刷新逻辑全部收敛到基础设施层,业务代码零侵入。方案本身不难,难的是密钥管理和令牌生命周期的工程化设计,把这些细节处理好,JWE 在真实项目中就能稳定发挥作用。