What it is
CSRF is when another website makes the victim's browser send a state-changing request to your application. The browser attaches cookies automatically to requests bound for that domain, so the request arrives with the victim's full session even though it originated on an entirely different page.
Why you should care
The most important thing about CSRF in 2026 is that browsers have largely fixed it, and that creates a new risk — people stop thinking about it.
Chrome made SameSite=Lax the default for cookies that do not declare one back in 2020, and Firefox and Safari followed. So classic CSRF (a cross-site form POST) no longer works on most configurations. But four gaps remain, and those are the four to go looking for:
SameSite=Nonefor a genuine business reason. Embedded widgets, SSO in an iframe, redirect-based payments — somebody setsNoneon a cookie and the browser default disappears.Laxdoes not protectGET. And a state-changingGETendpoint (GET /api/account/delete) passesLax— becauseLaxpermits top-level navigation.- Same site, different origin.
SameSiteis computed per site (eTLD+1), not per origin.evil.example.comandapp.example.comare the same site. A compromised subdomain, or a user-content page on a subdomain, walks straight throughSameSite=Lax. - An API using
Authorization: Bearerhas no CSRF — but if it also accepts cookies to serve the website, the cookie path still has CSRF.
And one thing rarely noticed: CSRF on the login endpoint (login CSRF) does not need the victim signed in. The attacker logs the victim into the attacker's account, and the victim then types their card number into it.
How the attack works
The mechanism is a feature of HTTP cookies: cookies follow the destination, not the origin. The browser does not care which page created the request.
sequenceDiagram autonumber actor V as Victim (signed into app) participant E as evil.example participant A as app.example V->>E: Opens any page E-->>V: HTML with a self-submitting form to app.example Note over V: The browser sends the request to app.example<br/>and ATTACHES the session cookie — it does<br/>not care which page created it. V->>A: POST /api/account/email<br/>Cookie: session=...<br/>email=attacker@evil.example Note over A: The app sees a valid request<br/>from an authenticated user. A-->>V: 200 — email changed Note over E: The attacker uses "forgot password"<br/>on the new address → account taken.The key point: the attacker cannot read the response. Same-origin policy blocks that. So CSRF is only useful for actions with side effects — which is why the fix only needs to cover state-changing methods.
The three SameSite values and what they stop:
| Value | Cookie sent when | Where CSRF remains |
|---|---|---|
Strict | Only when the request originates same-site | Almost nowhere — but a user clicking a link from email arrives signed out |
Lax (default) | Same-site, plus top-level GET navigation | State-changing GETs; and same-site-different-origin |
None | Always (must be paired with Secure) | Fully open — you need a CSRF token |
And one door SameSite does not close: an HTML <form> can only send a Content-Type of application/x-www-form-urlencoded, multipart/form-data or text/plain. An API that accepts only application/json effectively has a layer of protection — because a cross-origin fetch with Content-Type: application/json triggers a CORS preflight that gets blocked. But do not rely on that alone: it breaks the moment somebody adds [FromForm] or a permissive CORS policy.
Diagram description: Sequence diagram: a victim signed into app.example opens a page on evil.example. That page returns HTML containing a self-submitting form targeting app.example. The browser sends that POST and attaches the session cookie, because cookies follow the destination domain rather than the page that created the request. The app receives a request that looks entirely legitimate from an authenticated user and changes the account email. The attacker then uses forgot-password on the new address to take over the account.
Concrete example
An email-change endpoint. Three attack paths, matching the three gaps from block 2.
<!-- ① Classic CSRF. Blocked by SameSite=Lax — unless the cookie sets SameSite=None. --><form id="f" action="https://app.example/api/account/email" method="POST"> <input name="email" value="attacker@evil.example"></form><script>document.getElementById("f").submit()</script><!-- ② A state-changing GET. SameSite=Lax LETS IT THROUGH: top-level navigation. --><img src="https://app.example/api/account/delete?confirm=yes"> <!-- Or more directly: --><script>location = "https://app.example/api/account/delete?confirm=yes"</script><!-- ③ Same site, different origin. SameSite is computed per eTLD+1, so a page --><!-- hosted on usercontent.example IS same-site with app.example. --><!-- This is the gap an HTML upload field or a compromised subdomain creates. --><script> fetch("https://app.example/api/account/email", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "attacker@evil.example" }), });</script># And after the fix: a missing token gives 403, and it is logged as a signal.POST /api/account/email HTTP/1.1Cookie: session=…; __Host-csrf=abc123(no X-CSRF-Token header) HTTP/1.1 403 Forbidden{"errorCode":"ER_CSRF_TOKEN_MISSING"}// Program.csbuilder.Services.AddAuthentication().AddCookie(o =>{ // ❌ None vì "widget nhúng cần nó". Quyết định có lý do nghiệp vụ thật, nhưng nó // xoá mặc định Lax của trình duyệt — và không có token CSRF nào thay vào, // nên CSRF cổ điển sống lại nguyên vẹn. o.Cookie.SameSite = SameSiteMode.None; o.Cookie.SecurePolicy = CookieSecurePolicy.Always; o.Cookie.Name = "session"; // ❌ không có tiền tố __Host-}); // ❌ Không có AutoValidateAntiforgeryToken ở global. Bảo vệ theo từng endpoint là// mặc-định-MỞ, nên endpoint thứ 41 sẽ thiếu. [HttpPost("/api/account/email")]public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest req, CancellationToken ct){ // ❌ Không token CSRF, không kiểm Origin, và không yêu cầu mật khẩu hiện tại. // Một form tự submit từ evil.example là đủ để đổi email — rồi "quên mật khẩu" // trên địa chỉ mới là account takeover. var user = await _users.GetAsync(_currentUser.UserId, ct); user.ChangeEmail(req.Email, DateTime.UtcNow); await _users.SaveAsync(user, ct); // ❌ Và xác nhận gửi tới địa chỉ MỚI: kẻ tấn công tự xác nhận thay đổi của mình. await _mail.SendConfirmationAsync(req.Email, ct); return Ok();} // ❌ GET có tác dụng phụ. SameSite=Lax CHO ĐI QUA (navigation cấp cao), và mọi// antiforgery filter đều BỎ QUA GET theo thiết kế — nên không lớp nào bảo vệ nó.// Endpoint này gần như luôn tồn tại vì một link trong email.[HttpGet("/api/account/delete")]public async Task<IActionResult> Delete([FromQuery] string confirm, CancellationToken ct){ if (confirm == "yes") await _users.SoftDeleteAsync(_currentUser.UserId, ct); return Ok();}// Express + double-submit cookieapp.use((req, res, next) => { if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) { const fromCookie = req.cookies["csrf"]; const fromHeader = req.get("X-CSRF-Token"); // ❌ Phép so ĐÚNG, và bản vá vẫn vô nghĩa. Cookie tên "csrf" không có tiền tố // __Host-, nên một subdomain (usercontent.example, hay một subdomain bị chiếm) // đặt được cookie đó với Domain=.example — rồi nó biết giá trị và copy sang // header. Hai bên khớp, và request đi qua. if (!fromCookie || fromCookie !== fromHeader) { return res.status(403).json({ error: "csrf" }); } } next();});What happened in the wild
Netflix, 2006 (Dave Ferguson). A CSRF chain let an attacker add DVDs to the victim's rental queue, change the shipping address, and change the email and password — a complete account takeover. Worth reading because it is the clearest demonstration that CSRF is not a "low" bug: the chain ends in takeover, and not one line of JavaScript ran on Netflix's origin.
ING Direct, 2008. CSRF allowed transfers between the victim's accounts and the creation of new ones. Documented in Zeller & Felten's Princeton study alongside cases at YouTube, MetaFilter and The New York Times. That paper's lesson still holds: CSRF is a bug of omission, so there is nothing in the code to find.
And a note on the present: major CSRF incidents dropped sharply after 2020, and the cause was the browsers' SameSite=Lax default — not applications being fixed. This is why block 2 stresses the four remaining gaps: they are the places the browser default cannot reach.
How to defend
Correct cookie attributes — the cheapest fix, and it closes most of the surface
mandatoryOne line of configuration, and it makes classic CSRF stop working:
options.Cookie.SameSite = SameSiteMode.Lax; // or Strictoptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;options.Cookie.HttpOnly = true;options.Cookie.Name = "__Host-session"; // see belowStrict versus Lax is a product decision, not a security one: Strict blocks more but a user clicking a link from email arrives signed out. The workable pattern: Strict for the session cookie, plus a second Lax cookie carrying only "this person has signed in before" so you can show a welcome screen instead of an empty login page.
The __Host- prefix is the commonly skipped part and it matters more than it looks. It forces the cookie to have Secure, Path=/, and no Domain — meaning a subdomain cannot overwrite it. That is exactly the control that closes gap ③ from block 2 (same site, different origin): without __Host-, a compromised subdomain can set its own CSRF cookie and defeat double-submit.
// ── Program.cs ──────────────────────────────────────────────────────────────builder.Services.AddAuthentication().AddCookie(o =>{ // Lax là mặc định của trình duyệt; viết ra để nó không phụ thuộc vào trình duyệt // của người dùng và để người đọc sau biết đây là lựa chọn có ý thức. o.Cookie.SameSite = SameSiteMode.Lax; o.Cookie.SecurePolicy = CookieSecurePolicy.Always; o.Cookie.HttpOnly = true; // Tiền tố __Host- là phần hay bị bỏ và nó quan trọng hơn vẻ ngoài: nó BUỘC cookie // phải có Secure, Path=/, và KHÔNG có Domain — nghĩa là một subdomain không thể // ghi đè nó. Đây chính là biện pháp đóng lỗ "cùng site, khác origin", thứ mà // SameSite không đóng được vì SameSite tính theo eTLD+1. o.Cookie.Name = "__Host-session";}); builder.Services.AddAntiforgery(o =>{ o.HeaderName = "X-CSRF-Token"; // SPA copy từ cookie sang header o.Cookie.Name = "__Host-csrf"; // cùng lý do như trên o.Cookie.SecurePolicy = CookieSecurePolicy.Always; // HttpOnly = false CÓ CHỦ Ý và là ngoại lệ duy nhất: client phải đọc được token // này để copy sang header. Nó không phải secret dài hạn — giá trị của nó nằm ở // chỗ một trang KHÁC ORIGIN không đọc được cookie của ta để copy sang. o.Cookie.HttpOnly = false;}); // Mặc-định-ĐÓNG. Auto bật cho MỌI method không an toàn và tự bỏ qua GET/HEAD, nên// một controller mới thêm vào được bảo vệ mà không cần ai nhớ. Chiều này là điểm// chính: bảo vệ theo từng endpoint là mặc-định-MỞ.builder.Services.AddControllers(o => o.Filters.Add(new AutoValidateAntiforgeryTokenAttribute())); // ── Lớp 2 · Sec-Fetch-Site ─────────────────────────────────────────────────/// <summary>/// Bắt cái lớp 1 sẽ bỏ sót: một endpoint mới không đi qua filter, hoặc cấu hình/// cookie bị đổi trong một lần deploy.////// Sec-Fetch-Site do TRÌNH DUYỆT đặt và JavaScript không sửa được — nên nó không dựa/// vào việc ứng dụng nói gì. Nhưng nó KHÔNG thay được token: một subdomain bị chiếm/// gửi tới đúng giá trị "same-site"./// </summary>public sealed class FetchMetadataMiddleware(RequestDelegate next, ILogger<FetchMetadataMiddleware> log){ private static readonly string[] Unsafe = ["POST", "PUT", "PATCH", "DELETE"]; public async Task InvokeAsync(HttpContext ctx) { if (Unsafe.Contains(ctx.Request.Method)) { var site = ctx.Request.Headers["Sec-Fetch-Site"].FirstOrDefault(); // Chặn khi header CÓ và LÀ cross-site. Thiếu header thì KHÔNG chặn — // trình duyệt cũ không gửi nó, và fail-closed ở đây chặn người dùng thật. // Thiếu thì rơi về token CSRF ở lớp 1, và đó là lý do lớp 1 phải có. if (site == "cross-site") { // Tín hiệu độ nhiễu rất thấp: client của ta không bao giờ tạo ra nó. log.LogWarning("Chặn request cross-site tới {Path} từ {Origin}", ctx.Request.Path, ctx.Request.Headers.Origin.FirstOrDefault()); ctx.Response.StatusCode = 403; return; } } await next(ctx); }} // ── Endpoint ────────────────────────────────────────────────────────────────[HttpPost("/api/account/email")]public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest req, CancellationToken ct){ var user = await _users.GetAsync(_currentUser.UserId, ct) ?? throw new NotFoundException(IdentityErrorsList.USER_NOT_FOUND); // Lớp 3 — mật khẩu hiện tại. Kẻ tấn công không có nó, nên chuỗi Netflix ở khối 5 // dừng ngay tại bước đầu KỂ CẢ khi cả hai lớp trên bị vòng qua. if (!_hasher.Verify(user.PasswordHash, req.CurrentPassword)) throw new ApplicationGeneralException(IdentityErrorsList.INVALID_PASSWORD); // Không đổi ngay: ghi một yêu cầu chờ, và xác nhận đi tới địa chỉ CŨ. // Gửi tới địa chỉ mới thì kẻ tấn công tự xác nhận thay đổi của mình — chi tiết // này quyết định bản vá có nghĩa hay không. var request = user.RequestEmailChange(req.Email, DateTime.UtcNow); await _users.SaveAsync(user, ct); await _mail.SendEmailChangeNoticeAsync(user.Email, request.Token, ct); return Accepted();} /// <summary>/// GET không có tác dụng phụ — nó render một trang CÓ NÚT, và nút gửi POST.////// Vì sao phải thế: Lax cho GET đi qua khi đó là navigation cấp cao, và mọi/// antiforgery filter đều BỎ QUA GET theo thiết kế. Một GET có tác dụng phụ là một/// endpoint không lớp nào bảo vệ./// </summary>[HttpGet("/account/delete")]public IActionResult DeleteConfirmationPage() => View(); [HttpDelete("/api/account")]public async Task<IActionResult> Delete([FromBody] ConfirmRequest req, CancellationToken ct){ var user = await _users.GetAsync(_currentUser.UserId, ct) ?? throw new NotFoundException(IdentityErrorsList.USER_NOT_FOUND); if (!_hasher.Verify(user.PasswordHash, req.CurrentPassword)) throw new ApplicationGeneralException(IdentityErrorsList.INVALID_PASSWORD); await _users.SoftDeleteAsync(user.Id, ct); return NoContent();}import { randomBytes, timingSafeEqual } from "node:crypto"; const UNSAFE = new Set(["POST", "PUT", "PATCH", "DELETE"]); // Tiền tố __Host- là phần làm double-submit thật sự an toàn: nó BUỘC Secure, Path=/,// và KHÔNG Domain — nên một subdomain không thể đặt hay ghi đè cookie này. Không có// nó, double-submit chỉ là một phép so mà kẻ tấn công kiểm soát cả hai phía.const CSRF_COOKIE = "__Host-csrf";const CSRF_HEADER = "x-csrf-token"; function issueToken(res: Response): string { const token = randomBytes(32).toString("base64url"); res.cookie(CSRF_COOKIE, token, { secure: true, // __Host- yêu cầu path: "/", // __Host- yêu cầu sameSite: "lax", // httpOnly: false CÓ CHỦ Ý — client phải đọc được để copy sang header. Giá trị // của cơ chế nằm ở chỗ một trang KHÁC ORIGIN không đọc được cookie này. httpOnly: false, }); return token;} function safeEqual(a: string, b: string): boolean { const ba = Buffer.from(a), bb = Buffer.from(b); // Độ dài khác nhau thì timingSafeEqual ném lỗi, nên kiểm trước. Và so hằng thời // gian vì một phép so thoát sớm rò từng byte của token qua thời gian phản hồi. return ba.length === bb.length && timingSafeEqual(ba, bb);} app.use((req, res, next) => { // Mặc-định-ĐÓNG: mọi method không an toàn đều phải qua, không có allowlist endpoint. if (!UNSAFE.has(req.method)) return next(); // Lớp 2 — Sec-Fetch-Site. Chặn khi CÓ và LÀ cross-site; thiếu thì rơi về token. if (req.get("sec-fetch-site") === "cross-site") { req.log.warn({ origin: req.get("origin"), path: req.path }, "chặn request cross-site"); return res.status(403).json({ error: "cross_site" }); } const fromCookie = req.cookies[CSRF_COOKIE]; const fromHeader = req.get(CSRF_HEADER); if (!fromCookie || !fromHeader || !safeEqual(fromCookie, fromHeader)) { // Tín hiệu độ nhiễu rất thấp: client của ta không bao giờ tạo ra nó. req.log.warn({ origin: req.get("origin"), path: req.path }, "csrf token không khớp"); return res.status(403).json({ error: "csrf" }); } next();});A CSRF token on every state-changing request
mandatoryThe fix that does not depend on browser defaults. You need it because of the four gaps in block 2, and you need it especially when the cookie must be SameSite=None.
Use the framework's mechanism, do not write your own. ASP.NET Core ships antiforgery: [ValidateAntiForgeryToken], or [AutoValidateAntiforgeryToken] as a global filter — pick the Auto variant because it is on by default for every unsafe method and skips GET/HEAD itself. That default direction is the point: a newly added controller must be protected without anyone remembering.
Two patterns, and the second only where the server holds no state:
- Synchronizer token: generated server-side, tied to the session, compared server-side. The safest.
- Double-submit cookie: the token lives in a cookie and in a header; the server compares them. Stateless, but it is only safe when the cookie carries the
__Host-prefix — otherwise a subdomain can set both values and the comparison becomes meaningless.
For a SPA: put the token in a JS-readable cookie (not HttpOnly — a deliberate exception, since this token is not a long-lived secret) and have the client copy it into an X-CSRF-Token header. This works because a cross-origin page cannot read your cookie in order to copy it into the header.
No GET endpoint changes state
mandatoryThe fix for gap ② in block 2, and it is both an HTTP rule and a security rule. Lax lets GET through on top-level navigation, and both the antiforgery filter and AutoValidateAntiforgeryToken skip GET by design — so a state-changing GET is protected by no layer at all.
The rule: GET and HEAD must be safe in the RFC 9110 sense — no side effects. Deleting is DELETE, changing is PATCH/PUT, creating is POST.
A cheap check that catches it: grep GET action names containing action verbs (Delete, Remove, Cancel, Approve, Confirm, Reset, Send) — see block 7. In practice these endpoints almost always exist because of a link in an email ("click here to confirm") — which is why it is worth having a GET endpoint that renders a page with a button, and the button sends the POST.
Check `Origin`/`Sec-Fetch-Site` as a second layer
This layer catches what layer 1 will miss: a new endpoint that bypasses the filter, or a cookie configuration changed in some deploy.
Sec-Fetch-Siteis set by the browser and JavaScript cannot forge it.same-originorsame-siteis acceptable;cross-siteis rejected. It is the strongest check in this layer because it does not depend on what the application says.Originis present on every cross-originPOST(and on same-origin ones in current browsers). Compare it against your host allowlist.
Two things to be careful about, or this layer becomes a hole:
- What do you do when the header is absent? Older browsers do not send
Sec-Fetch-Site— so failing closed blocks real users. The correct pattern: reject when the header is present and equalscross-site; when absent, fall back to the layer-1 CSRF token. - Do not use it instead of a token. It is layer 2 because it cannot close gap ③ (same site, different origin) —
Sec-Fetch-Site: same-siteis exactly what a compromised subdomain sends.
Re-authenticate for sensitive actions, and treat a missing token as a signal
CSRF only works when the action has an immediate, single-shot effect. For the most sensitive actions, breaking that is the most effective control, and it depends on none of the layers above:
- Require the current password to change email, change password, add an API key, or disable MFA. The attacker does not have the password, so the Netflix chain in block 5 stops at step one.
- Confirm by email for email changes: send the link to the old address, not the new one. That is the deciding detail — sending it to the new address lets the attacker confirm their own change.
- Step-up MFA for transfers and account deletion.
And on detection: a request missing its CSRF token, or carrying an unexpected Origin, is a very low-noise signal — your own client never produces one. So do not just return a silent 403: log it with Origin and Referer, and alert on the rate. Layer 3 because it blocks nothing, but for a family where the victim never knows they were attacked, it is the only thing that tells you.
Verifying the fix
1. Unit tests: every unsafe method MUST reject a request with no token. And more importantly a counting test — enumerate every POST/PUT/PATCH/DELETE endpoint from EndpointDataSource and assert each carries antiforgery. A new endpoint without it fails the build. Same technique as AuthzCoverage in the access-control topic. See the csharp / test tab.
2. A merge-blocking grep for state-changing GETs (gap ②) — the cheapest check here:
grep -rnE '\[HttpGet' -A3 --include='*.cs' src/ \ | grep -iE 'public .*(Delete|Remove|Cancel|Approve|Confirm|Reset|Send|Revoke|Disable)' \ && { echo "state-changing GET — no layer protects it"; exit 1; }exit 03. Check the cookie attributes on a real response — the layer-1 configuration lives in Program.cs and can change in a deploy with no test going red:
S=$(curl -si -X POST https://staging.example.com/api/auth/login \ -d '{"email":"test@x.com","password":"…"}' -H 'Content-Type: application/json' \ | grep -i '^set-cookie:' | tr -d '\r')echo "$S" | grep -qi 'samesite=' || { echo "cookie missing SameSite"; exit 1; }echo "$S" | grep -qi 'httponly' || { echo "session cookie missing HttpOnly"; exit 1; }echo "$S" | grep -qi 'secure' || { echo "cookie missing Secure"; exit 1; }# And if you use double-submit, the CSRF cookie MUST carry the __Host- prefixecho "$S" | grep -qi 'csrf' && echo "$S" | grep -q '__Host-' \ || echo "WARNING: CSRF cookie lacks __Host- — double-submit is bypassable from a subdomain"4. An end-to-end check with a real cross-origin request. This is the only check that reproduces the actual conditions: stand up a page on another origin in Playwright, sign into the app in that context, submit the cross-origin form, and assert the state did not change. A server-side test cannot catch gap ② because it has no browser to apply SameSite.
5. Check sensitive actions genuinely require the password (layer 3):
# An email change WITHOUT currentPassword must be 4xxcurl -s -o /dev/null -w '%{http_code}\n' -X POST "$B/api/account/email" \ -b "$COOKIES" -H "X-CSRF-Token: $T" -H 'Content-Type: application/json' \ -d '{"email":"new@x.com"}' # must be 422, not 200public class CsrfTests : IClassFixture<ApiFixture>{ private readonly ApiFixture _fx; public CsrfTests(ApiFixture fx) => _fx = fx; /// <summary> /// Phép kiểm quan trọng nhất của topic này, và nó là một phép ĐẾM chứ không phải /// một phép thử: liệt kê MỌI endpoint không an toàn và khẳng định từng cái có /// antiforgery. Một endpoint mới thêm tuần sau tự động nằm trong phạm vi. /// /// Cùng kỹ thuật với AuthzCoverage ở topic access-control, và cùng lý do: CSRF là /// lỗi của việc THIẾU một thứ, nên code review không thấy nó — không ai nhận ra /// một attribute không có mặt trong diff. /// </summary> [Fact] public void Every_unsafe_endpoint_has_antiforgery() { string[] unsafeMethods = ["POST", "PUT", "PATCH", "DELETE"]; var unprotected = _fx.Services.GetRequiredService<EndpointDataSource>().Endpoints .OfType<RouteEndpoint>() .Where(e => e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods .Any(unsafeMethods.Contains) == true) // Endpoint dùng bearer token thì không có CSRF — trình duyệt không tự gửi // Authorization header. Miễn tường minh, và danh sách miễn phải ngắn. .Where(e => e.Metadata.GetMetadata<BearerOnlyAttribute>() is null) .Where(e => e.Metadata.GetMetadata<IAntiforgeryMetadata>() is null or { RequiresValidation: false }) .Select(e => e.RoutePattern.RawText) .ToList(); Assert.Empty(unprotected); } /// <summary> /// GET phải AN TOÀN theo nghĩa RFC 9110. Test này bắt lỗ mà không lớp nào bảo vệ: /// Lax cho GET đi qua, và antiforgery bỏ qua GET theo thiết kế. /// </summary> [Fact] public void No_get_endpoint_has_a_side_effecting_name() { string[] verbs = ["Delete", "Remove", "Cancel", "Approve", "Confirm", "Reset", "Send", "Revoke", "Disable"]; var suspicious = _fx.Services.GetRequiredService<EndpointDataSource>().Endpoints .OfType<RouteEndpoint>() .Where(e => e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains("GET") == true) .Where(e => verbs.Any(v => e.DisplayName?.Contains(v, StringComparison.OrdinalIgnoreCase) == true)) .Select(e => e.DisplayName) .ToList(); Assert.Empty(suspicious); } [Fact] public async Task Post_without_csrf_token_is_rejected() { var client = await _fx.SignedInClientWithoutCsrfAsync(_fx.Bob); var res = await client.PostAsJsonAsync("/api/account/email", new { email = "attacker@evil.example", currentPassword = "correct-horse" }); Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); // Khẳng định ở TẦNG DỮ LIỆU: một 400 sau khi đã ghi vẫn pass khẳng định về // status code, và đó là loại bản vá nửa vời cần bắt. Assert.Equal(_fx.Bob.Email, (await _fx.GetUserFromDbAsync(_fx.Bob.Id)).Email); } /// <summary>Lớp 2 — Sec-Fetch-Site: cross-site bị chặn dù token có hợp lệ.</summary> [Fact] public async Task Cross_site_request_is_rejected_even_with_a_valid_token() { var (client, token) = await _fx.SignedInClientWithCsrfAsync(_fx.Bob); var req = new HttpRequestMessage(HttpMethod.Post, "/api/account/email") { Content = JsonContent.Create(new { email = "attacker@evil.example", currentPassword = "correct-horse" }), }; req.Headers.Add("X-CSRF-Token", token); req.Headers.Add("Sec-Fetch-Site", "cross-site"); Assert.Equal(HttpStatusCode.Forbidden, (await client.SendAsync(req)).StatusCode); } /// <summary>Thiếu header thì KHÔNG chặn — trình duyệt cũ không gửi nó.</summary> [Fact] public async Task Missing_fetch_metadata_falls_back_to_the_token() { var (client, token) = await _fx.SignedInClientWithCsrfAsync(_fx.Bob); var req = new HttpRequestMessage(HttpMethod.Post, "/api/account/email") { Content = JsonContent.Create(new { email = "new@acme.com", currentPassword = "correct-horse" }), }; req.Headers.Add("X-CSRF-Token", token); // không có Sec-Fetch-Site Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(req)).StatusCode); } /// <summary> /// Cấu hình cookie sống trong Program.cs, nên nó bị đổi trong một lần deploy mà /// không test nào đỏ. Test này là thứ làm nó đỏ — kể cả tiền tố __Host-, thứ mà /// không có nó thì double-submit vòng qua được từ một subdomain. /// </summary> [Fact] public async Task Session_and_csrf_cookies_have_the_required_attributes() { var res = await _fx.Client.PostAsJsonAsync("/api/auth/login", new { email = _fx.Bob.Email, password = "correct-horse" }); var cookies = res.Headers.GetValues("Set-Cookie").ToList(); var session = Assert.Single(cookies, c => c.Contains("session")); Assert.Contains("__Host-", session); Assert.Contains("HttpOnly", session, StringComparison.OrdinalIgnoreCase); Assert.Contains("Secure", session, StringComparison.OrdinalIgnoreCase); Assert.Contains("SameSite=Lax", session, StringComparison.OrdinalIgnoreCase); var csrf = Assert.Single(cookies, c => c.Contains("csrf")); Assert.Contains("__Host-", csrf); // Và cookie CSRF cố ý KHÔNG HttpOnly — client phải đọc được để copy sang header. Assert.DoesNotContain("HttpOnly", csrf, StringComparison.OrdinalIgnoreCase); }}Common mistakes
| The "fix" | Why it is wrong |
|---|---|
Check Referer | Absent under many privacy configurations, and your own Referrer-Policy: no-referrer strips it. Use Origin or Sec-Fetch-Site |
| A CSRF token in a cookie only | The browser sends cookies automatically, so it arrives on cross-site requests too. The token must be in a header or body — somewhere only same-origin code can set |
Double-submit with no __Host- on the cookie | A subdomain can set both the cookie and the header. The comparison still matches, and the fix means nothing |
"SameSite=Lax is enough" | It does not protect state-changing GETs, and it does not protect same-site-different-origin |
SameSite=None "so the widget works", then forgetting the token | This is where classic CSRF comes back intact |
| Per-endpoint protection, adding the attribute one by one | Endpoint 41 will be missing it. A global AutoValidateAntiforgeryToken is closed-by-default |
| Relying on the API accepting only JSON | True against HTML forms, but it breaks the moment somebody adds [FromForm] or a permissive CORS policy. It is a consequence, not a fix |
| One token shared across all sessions | The attacker fetches their own token and uses it for the victim. Tokens must be bound to the session |
| Changing the email and confirming to the NEW address | The attacker confirms their own change. It must go to the old address |
The generational mistake, and it is this topic's central one in 2026: "browsers handle SameSite now". True for default configurations, and the four gaps in block 2 are precisely where that default does not reach. Plus one more point: you do not control your users' browsers.
The severity mistake: rating CSRF "medium" because the attacker cannot read the response. The Netflix chain in block 5 ends in account takeover without reading a single response — change the email, then use forgot-password.
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…