
最近在和几个开发者聊安全话题,有人问我:"CSRF 攻击真的那么危险吗?"
我回答说:危险的不是 CSRF 本身,而是浏览器那个看似"便利"的自动提交特性。
很多人把 CSRF(跨站请求伪造)当成一种漏洞来对待,但实际上它更像是浏览器设计中的一个"困局"——一个为了用户体验而埋下的安全地雷。要理解这个困局有多深,咱们得从 Cookie 自动提交说起。
想象一下,你现在登录了淘宝账户。浏览器收到服务器的 Set-Cookie 指令,把 session 信息存到本地。
然后你在淘宝浏览商品时,突然想起来去 V2EX 看看技术讨论。打开新标签,访问 v2ex.com。
这时候,很关键的事情发生了:当你的浏览器向 v2ex.com 发送请求时,它仍然会自动在请求里带上淘宝的 Cookie。
等等,这是咋回事?跨域请求,浏览器不是应该有"同源策略"的吗?
别急,这里有个细节:同源策略主要是限制 JavaScript 读取跨域的响应内容,但它不禁止浏览器在跨域请求中自动带上 Cookie。这就像你在路上,虽然陌生人看不到你钱包里的钱(跨域限制),但你自己的钱包仍然会跟着你走。
这个"自动带 Cookie"的设计,当初是为了用户体验。比如你在论坛发帖后刷新,浏览器需要自动认证你的身份,不然每次都得重新登录。很合理,对吧?
但这就埋下了 CSRF 的种子。
现在考虑一个场景。恶意网站 evil.com 的页面上,有这么一行代码:
<img src="https://bank.com/api/transfer?amount=10000&to=attacker" />
一个图片标签而已。当你访问 evil.com 时,浏览器会尝试加载这张图片。虽然这个"图片"其实是一个转账请求。
关键来了:浏览器会自动在这个请求里带上你在 bank.com 的 Cookie。服务器看到一个合法的 Session ID,就认为这是你本人在操作。
你的浏览器 evil.com bank.com
│ │ │
├──访问────────────> │ │
│ │ │
│ <img src="bank.com/transfer"> │
│ │ │
│ 尝试加载图片 │ │
│ ├──GET /transfer?...───>│
│ │ (自动附带 Cookie) │
│ │ │
│ │ [验证 Cookie]
│ │ [执行转账]
│ │<──200 OK────────────│
│ │ │
转账完成了,而且用户可能根本不知道发生了什么。
这就是 CSRF 的本质:不是服务器的认证有问题,也不是用户密码被破解,而是浏览器在跨域请求中自动提交 Cookie 这个"特性",被恶意利用了。
漏洞通常指的是代码的 bug。但 CSRF 不是 bug,浏览器的自动 Cookie 提交正常工作着。问题在于这个设计本身与现代 Web 的安全需求产生了矛盾。
换个角度想:如果浏览器禁止所有跨域请求自动带 Cookie,那很多合法的场景(比如第三方支付回调、OAuth 回跳)就会破裂。但如果允许自动带,就打开了 CSRF 的大门。
这就是困局。浏览器的设计者早就知道这个风险,但没法简单地"关闭"这个特性,因为生态里太多场景依赖它了。
既然浏览器会自动提交 Cookie,那咱们就加一层防护:额外的秘密信息。
这个秘密就是 CSRF Token。核心思路是:
Cookie 可以被自动提交,但只有我服务器知道的秘密 Token 不行。恶意网站看不到这个秘密,所以无法伪造完整的请求。
具体怎么实现?先看服务端生成 Token:
import express from"express";
import crypto from"crypto";
import session from"express-session";
const app = express();
app.use(express.json());
app.use(
session({
secret: "super-secret-key",
resave: false,
saveUninitialized: true
})
);
// 生成随机 Token
function generateCSRFToken() {
return crypto.randomBytes(32).toString("hex");
}
// 每个会话生成一个 Token,存在服务端
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = generateCSRFToken();
}
next();
});
服务器通过 API 把这个 Token 发给客户端(注意,不是通过 Cookie,而是通过 JSON 或 HTML):
app.get("/api/csrf-token", (req, res) => {
res.json({ csrfToken: req.session.csrfToken });
});
客户端拿到 Token,存在内存里,然后在发送"有状态操作"(POST、PUT、DELETE)时,把 Token 放到请求头里:
let csrfToken = null;
asyncfunction fetchCSRFToken() {
const response = await fetch("/api/csrf-token");
const data = await response.json();
csrfToken = data.csrfToken;
}
await fetchCSRFToken();
asyncfunction transferMoney(amount, to) {
const response = await fetch("/api/transfer", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken // 关键:Token 在请求头里
},
body: JSON.stringify({ amount, to })
});
return response.json();
}
服务端验证这个 Token:
function verifyCSRF(req, res, next) {
const token = req.headers["x-csrf-token"];
if (!token || token !== req.session.csrfToken) {
return res.status(403).json({ error: "Invalid CSRF token" });
}
next();
}
app.post("/api/transfer", verifyCSRF, (req, res) => {
const { amount, to } = req.body;
console.log(`Transfer ${amount} to ${to}`);
res.json({ success: true });
});
为什么这样就安全了?
恶意网站 evil.com bank.com
│ │
├─ 尝试伪造请求 │
│ (有 Cookie,但没 Token) │
│ │
├──POST /transfer──────────> │
│ Headers: { │
│ Cookie: session=xxx, │ ← 自动提交
│ X-CSRF-Token: ??? │ ← 缺少正确的 Token
│ } │
│ [验证失败]
│ [拒绝请求]
│<──403 Forbidden─────────────│
恶意网站可以让浏览器自动带上 Cookie,但它看不到页面内容(同源策略),所以无法读取 Token 的值。没有正确的 Token,请求就会被拒绝。
还有一种防护思路,叫"双重提交"(Double Submit Cookie)。它的逻辑是:
我在 Cookie 里放一个随机值,同时也要求你在请求体或请求头里带上同一个值。如果两者都对上了,说明这个请求来自真正的用户。
为什么这样也能行?因为恶意网站即使能让浏览器自动提交 Cookie,但它写不了 Cookie(受同源策略限制)。所以恶意网站无法伪造那个额外的值。
这是怎么实现的:
app.get("/login-success", (req, res) => {
const csrfToken = generateCSRFToken();
// 在 Cookie 里存一份 Token
res.cookie("csrfToken", csrfToken, {
httpOnly: false, // 注意:必须允许 JS 读取
sameSite: "Strict"
});
// 同时也返回给客户端
res.json({ csrfToken });
});
客户端读取 Cookie,然后在请求时也提交这个值:
function getCookie(name) {
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [key, value] = cookie.trim().split("=");
if (key === name) {
return value;
}
}
returnnull;
}
asyncfunction updateProfile(data) {
const token = getCookie("csrfToken");
const response = await fetch("/api/profile", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": token
},
body: JSON.stringify(data)
});
return response.json();
}
服务端验证:
function verifyDoubleSubmit(req, res, next) {
const cookieToken = req.cookies.csrfToken;
const headerToken = req.headers["x-csrf-token"];
if (!cookieToken || cookieToken !== headerToken) {
return res.status(403).send("CSRF validation failed");
}
next();
}
这个方案的好处是不需要服务端维护 Token 状态,缺点是 Cookie 要暴露给 JavaScript(设置 httpOnly: false),稍微增加了 XSS 的风险。
Google 等大公司后来意识到,与其靠应用层的 Token,不如从浏览器层面彻底改变 Cookie 的行为。
这就是 SameSite 属性。它告诉浏览器:只有在同一站点的请求中,才自动提交这个 Cookie。跨站请求就别带了。
res.cookie("sessionId", "abc123", {
httpOnly: true,
secure: true,
sameSite: "Strict" // ← 关键
});
SameSite 有三个值:
值 | 行为 | 安全性 |
|---|---|---|
Strict | 任何跨站请求都不带 Cookie | 最安全,但可能破坏合法场景 |
Lax | 只有顶级导航(比如链接跳转)才带 Cookie,POST 等请求不带 | 平衡点 |
None | 所有跨站请求都带 Cookie | 最不安全,但某些场景必需 |
现代浏览器的默认行为逐渐从 SameSite=None 转向 SameSite=Lax。这意味着单靠浏览器的演进,CSRF 风险已经大幅降低。
但这也说明了一个问题:CSRF 防护需要多个层面的配合——应用层(Token)、HTTP 层(SameSite)、框架层(中间件自动化)。没有一个银弹。
到这里,咱们看到了整个防护体系。但问题还没完全解决,因为存在一些现实的制约:
这就是为什么我说 CSRF 是个"困局"。不是因为防护方案不存在,而是:
那在 2026 年,咱们到底该怎么做?
我的建议是 分层防护:
第一层:浏览器默认
设置 SameSite=Strict 或 Lax(取决于你是否需要跨站 Cookie 传递)。这是成本最低的防护。
// 针对认证 Cookie
res.cookie("session", sessionId, {
httpOnly: true,
secure: true,
sameSite: "Strict" // 默认就这么设
});
第二层:应用层 Token
对于会改变数据的操作(POST、PUT、DELETE),都要验证 CSRF Token。这可以通过中间件自动化。
在 Express 生态里,直接用 csurf 中间件:
import csurf from "csurf";
import cookieParser from "cookie-parser";
app.use(cookieParser());
const csrfProtection = csurf({ cookie: true });
app.get("/form", csrfProtection, (req, res) => {
res.send(`
<form action="/submit" method="POST">
<input type="hidden" name="_csrf" value="${req.csrfToken()}">
<input type="text" name="data">
<button type="submit">Submit</button>
</form>
`);
});
app.post("/submit", csrfProtection, (req, res) => {
res.send("Form submitted safely");
});
对于 SPA,在 HTTP 客户端层面集中处理 Token:
async function apiRequest(url, options = {}) {
const token = await getCSRFToken();
const headers = {
"Content-Type": "application/json",
"X-CSRF-Token": token,
...(options.headers || {})
};
const response = await fetch(url, {
...options,
headers,
credentials: "include"// 带上 Cookie
});
if (!response.ok) {
thrownewError("Request failed");
}
return response.json();
}
// 所有 POST、PUT、DELETE 都用这个函数
apiRequest("/api/transfer", {
method: "POST",
body: JSON.stringify({ amount: 1000, to: "account123" })
});
第三层:安全意识
定期审查自己的应用是否有 XSS 漏洞(因为 XSS 会破坏 Token 防护),是否正确地实现了认证逻辑,是否有其他的状态改变操作被遗漏了。
CSRF 不是漏洞,是浏览器为了便利用户而做的"特性"。这个特性在现代 Web 安全的背景下,成了一个困局。
我们无法通过禁用自动 Cookie 提交来根本解决(因为生态依赖它),所以只能在应用层、HTTP 层、框架层加多道防线。
如果你现在还在纠结"要不要加 CSRF Token",答案是:必须要。不仅要有 Token,还要有 SameSite,还要有定期的安全审计。
这不是完美的解决方案,但在当下的 Web 生态里,这是我们能做到的最好的。
你在自己的应用里实现过 CSRF Token 吗?如果有,是用会话存储还是双重提交方案?如果没有,是因为框架自动化了,还是确实没重视?
或者你踩过什么安全方面的坑?评论区分享一下,咱们一起讨论 👇