Antiforgery & CORS
Key Points
- Antiforgery (CSRF) is needed when authentication is cookie-based and the browser auto-attaches the cookie. Pure-Bearer-token APIs don't need it.
- ASP.NET Core has built-in antiforgery: Razor injects tokens automatically;
.UseAntiforgery()validates on POST/PUT/DELETE/PATCH (since .NET 8). - CORS is a browser security mechanism. The server says "this origin is allowed"; the browser enforces. CORS doesn't protect the server — auth does.
AllowAnyOrigin().AllowCredentials()is invalid — browsers refuse the combination. Specify origins explicitly.- SameSite cookies (
Laxdefault in modern browsers) provide a strong CSRF mitigation by default; antiforgery is belt-and-braces.
Concepts (deep dive)
CSRF: the problem
User logs into bank.com → cookie set. User visits evil.com which has:
<form action="https://bank.com/transfer" method="post">
<input name="to" value="attacker">
<input name="amount" value="10000">
</form>
<script>document.forms[0].submit();</script>
Browser auto-attaches bank.com's cookie. Bank receives an authenticated transfer request. Without antiforgery, it succeeds.
Antiforgery in ASP.NET Core
builder.Services.AddAntiforgery();
app.UseAntiforgery(); // .NET 8+ middleware
// Razor form auto-injects token:
<form method="post">
<!-- @Html.AntiForgeryToken() implicit -->
</form>
// Controller action:
[HttpPost, ValidateAntiForgeryToken]
public IActionResult Transfer(TransferModel m) { /* ... */ }
// Minimal API (.NET 8+):
app.MapPost("/transfer", (...) => /* ... */).RequireAntiforgery();
The token has two parts: 1. Cookie token — set by app.UseAntiforgery(). 2. Form/header token — embedded in form or sent as RequestVerificationToken header.
Server verifies both match (and are bound to the user). Attacker can't read the form token (different origin), so the request fails.
Why does it work?
The Same-Origin Policy prevents evil.com from reading bank.com's response. The form token can only be obtained by a real bank.com page — impossible from evil.com.
When NOT needed
Pure-Bearer APIs:
Browser doesn't auto-attach Bearer headers — JS code must explicitly set them. JS on evil.com doesn't have access to the token (it's in localStorage of bank.com). No CSRF.
But: if you store the bearer in a cookie (which you should for SPAs!), you're back to needing antiforgery OR strict SameSite + custom headers.
SameSite cookies as CSRF defense
Strict— cookie never sent on cross-origin requests. Safest; breaks "click email link → land logged in".Lax(default) — cookie sent on top-level navigations (link clicks) but not POSTs. Good balance.None— cookie sent always. Required for cross-site OAuth flows. Must beSecure.
Lax mostly defeats CSRF on its own. Antiforgery is belt-and-braces.
Antiforgery in SPAs
For SPA + cookie auth:
// Server emits both tokens:
app.MapGet("/antiforgery/token", (IAntiforgery anti, HttpContext ctx) =>
{
var tokens = anti.GetAndStoreTokens(ctx);
return Results.Ok(new { token = tokens.RequestToken });
});
// SPA fetches token, includes in header:
const r = await fetch("/antiforgery/token", { credentials: "include" });
const { token } = await r.json();
await fetch("/api/transfer", {
method: "POST",
credentials: "include",
headers: { "RequestVerificationToken": token, "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
The RequestVerificationToken header is the default name. Configurable.
Custom header trick (no antiforgery)
Browsers don't send custom headers cross-origin without preflight. Requiring a custom header (X-Requested-With: XMLHttpRequest) is a partial CSRF defense — but only against simple-request attacks, not all of them. Use proper antiforgery.
CORS: the problem
Same-Origin Policy blocks JS on app.com from reading responses from api.com. Necessary for security (otherwise evil.com could read your bank cookie via fetch). But legitimate cross-origin APIs need a way to opt in.
CORS = the server explicitly tells the browser "yes, app.com is allowed to read my responses".
How CORS works
For a simple GET:
Request: GET /data Origin: https://app.com
Response: 200 OK Access-Control-Allow-Origin: https://app.com
Browser sees the header → JS gets the response. Without it, browser blocks the JS code from reading.
For "non-simple" requests (anything with custom headers, methods other than GET/POST/HEAD, or non-trivial Content-Types):
1. Preflight: OPTIONS /data Origin: https://app.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization
2. Server: 200 OK Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: Authorization
Access-Control-Max-Age: 600
3. Real: PUT /data Authorization: Bearer xyz Origin: https://app.com
Two round trips per "complex" request unless the preflight is cached (Max-Age).
ASP.NET Core CORS
builder.Services.AddCors(o =>
{
o.AddPolicy("Default", p => p
.WithOrigins("https://app.contoso.com", "https://admin.contoso.com")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromMinutes(10)));
});
app.UseCors("Default");
app.MapControllers(); // Or apply per-endpoint:
app.MapGet("/api", () => "x").RequireCors("Default");
Order: UseCors BEFORE UseAuthentication/UseAuthorization. Otherwise preflight (OPTIONS) is rejected by auth before CORS responds.
AllowAnyOrigin + AllowCredentials — invalid
Browsers refuse Access-Control-Allow-Origin: * paired with Access-Control-Allow-Credentials: true. Specify origins.
WithOrigins patterns
.WithOrigins("https://*.contoso.com").SetIsOriginAllowedToAllowWildcardSubdomains()
// Or fully programmatic:
.SetIsOriginAllowed(origin => Uri.TryCreate(origin, UriKind.Absolute, out var u) && u.Host.EndsWith(".contoso.com"))
Credentials
AllowCredentials() enables cookies and Authorization header on cross-origin requests. Required for cookie-auth SPAs across domains. Tightens to specific origins.
CORS doesn't authenticate
Common misconception: "CORS lets the API protect itself." False. CORS only tells the browser what's allowed. A non-browser client (curl, Postman, server-side proxy) ignores CORS. Auth + authorization remain the security boundary.
CORS in development
Avoid app.UseCors(p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()) "for now" — these settings leak to production. Use environment-specific config.
Code: correct vs wrong
❌ Wrong: missing antiforgery on cookie-auth POST
✅ Correct: validate
❌ Wrong: too-permissive CORS
✅ Correct: scoped
o.AddPolicy("Spa", p => p.WithOrigins("https://app.contoso.com")
.AllowAnyMethod().AllowAnyHeader().AllowCredentials());
❌ Wrong: UseCors after auth
✅ Correct: order
Design patterns for this topic
Pattern 1 — "SameSite=Lax + antiforgery"
- Intent: layered CSRF defense.
Pattern 2 — "Antiforgery token in custom header"
- Intent: SPA/JS clients send via header, not form.
Pattern 3 — "Per-environment CORS policy"
- Intent: strict in prod, permissive in dev — but never via toggle on prod.
Pattern 4 — "Preflight caching with Max-Age"
- Intent: reduce OPTIONS round-trips for hot APIs.
Pattern 5 — "Antiforgery only when cookies"
- Intent: pure-Bearer APIs skip; cookie-auth APIs require.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Antiforgery | Strong CSRF mitigation | SPA plumbing |
| SameSite=Lax | Default; mitigates most CSRF | Breaks some legitimate flows |
| CORS strict | Defense in depth | Friction for new clients |
| Preflight cache | Fewer OPTIONS | Cached errors persist |
When to use / when to avoid
- Always antiforgery for cookie-auth POST/PUT/DELETE.
- Always scoped CORS in production.
- Avoid antiforgery on pure-Bearer APIs — pointless.
- Avoid
AllowAnyOriginfor cross-domain SPA + credentials.
Interview Q&A
Q1. What's CSRF? Cross-Site Request Forgery — attacker tricks browser into making authenticated requests with the user's cookie.
Q2. Why doesn't antiforgery affect Bearer-token APIs? Browser doesn't auto-attach Bearer tokens. Cross-origin JS can't read the token from the victim's localStorage.
Q3. SameSite cookie modes? Strict (never cross-site), Lax (top-level nav only — default), None (always; requires Secure).
Q4. How does antiforgery validation work? Server sets a cookie token + a form/header token. Both must match and bind to the user. Attacker can't read the form token cross-origin.
Q5. What's CORS for? Letting the browser know which cross-origin requests are allowed. Doesn't protect the server.
Q6. Why is AllowAnyOrigin().AllowCredentials() invalid? Browsers refuse to send credentials to wildcard origins. Specify origins.
Q7. What's a CORS preflight? OPTIONS request before the real one for non-simple requests. Server responds with allowed methods/headers; browser then issues the real request.
Q8. Why preflight cache (Max-Age)? Avoids OPTIONS on every request; significant latency improvement.
Q9. Order of UseCors and UseAuthentication? CORS before auth — otherwise OPTIONS preflight gets rejected as unauthenticated.
Q10. SPA on api.contoso.com vs different origin — antiforgery needs? If SPA same-origin (or via reverse proxy → same origin), normal antiforgery works. Cross-origin requires CORS + token in header + credentials: include.
Q11. Is X-Requested-With: XMLHttpRequest a real CSRF defense? Partial. Browsers preflight requests with custom headers cross-origin. Not all attacks blocked. Use proper antiforgery.
Q12. What happens if I disable antiforgery and rely on SameSite alone? Lax handles 95% of CSRF. Edge cases (older browsers, top-level POST tricks) remain. Not zero risk.
Gotchas / common mistakes
- ⚠️ Forgetting antiforgery on cookie-auth APIs.
- ⚠️
AllowAnyOriginwith credentials — browsers reject; subtle bug. - ⚠️ CORS configured after auth middleware — preflight rejected.
- ⚠️ Caching CORS responses without
Vary: Origin— cross-origin pollution. - ⚠️
SameSite=NonewithoutSecure— modern browsers reject.