Skip to content

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 (Lax default 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:

Client → Authorization: Bearer xyz → Server

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 be Secure.

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

o.AddPolicy("Bad", p => p.AllowAnyOrigin().AllowCredentials());   // browsers reject

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

[HttpPost]
public IActionResult Transfer(TransferModel m) { /* ... */ }   // CSRF-able

✅ Correct: validate

[HttpPost, ValidateAntiForgeryToken]
public IActionResult Transfer(TransferModel m) { /* ... */ }

❌ Wrong: too-permissive CORS

o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader().AllowCredentials());

✅ Correct: scoped

o.AddPolicy("Spa", p => p.WithOrigins("https://app.contoso.com")
    .AllowAnyMethod().AllowAnyHeader().AllowCredentials());

❌ Wrong: UseCors after auth

app.UseAuthentication(); app.UseAuthorization(); app.UseCors();   // preflight rejected

✅ Correct: order

app.UseCors(); app.UseAuthentication(); app.UseAuthorization();

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 AllowAnyOrigin for 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.
  • ⚠️ AllowAnyOrigin with credentials — browsers reject; subtle bug.
  • ⚠️ CORS configured after auth middleware — preflight rejected.
  • ⚠️ Caching CORS responses without Vary: Origin — cross-origin pollution.
  • ⚠️ SameSite=None without Secure — modern browsers reject.

Further reading