CSRF β Cross-Site Request Forgery and How to Prevent It
How CSRF attacks work step-by-step, SameSite cookies as the modern defense, CSRF tokens, ASP.NET Core's built-in protection, and why JWT APIs are naturally immune.
The Attack β Step by Step
CSRF exploits the browser's automatic cookie behavior. Here's a concrete attack:
- You log in to
bank.example.com. The bank sets a session cookie. - You visit
evil.comin the same browser (another tab, or you were tricked into clicking a link). evil.comcontains this hidden HTML:
<!-- On evil.com -->
<form id="transfer" action="https://bank.example.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="5000" />
</form>
<script>document.getElementById('transfer').submit();</script>- The browser submits this form to
bank.example.com. Because you have a session cookie for that domain, the browser automatically includes it. - The bank's server receives a valid, authenticated POST request β it looks identical to a legitimate transfer.
- $5,000 transferred. You never saw a form.
Why This Works
The fundamental issue is that browsers send cookies for a domain with every request to that domain, regardless of where the request originated. This is essential for how the web works β but it means any site can trigger authenticated requests on your behalf.
CSRF does not require JavaScript. The HTML <form> tag alone is enough. A single <img src="https://bank.example.com/logout"> can trigger a GET-based logout.
SameSite Cookie Attribute β The Modern Defense
SameSite is a cookie attribute that tells the browser when to include the cookie in cross-site requests.
Response.Cookies.Append("session", token, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict // or Lax
});SameSite=Strict β the cookie is never sent on cross-site requests. Clicking a link from an email to your bank will not include the cookie (you'd need to reload the page). Highest protection, slightly awkward UX.
SameSite=Lax β cookies are sent on top-level GET navigations (clicking a link), but not on cross-origin POST, <img>, <iframe>, etc. Prevents the form-based attack above. This is the browser default in modern browsers.
SameSite=None β always sent cross-site (must also set Secure). Required for embedded widgets, cross-origin iframes, payment flows.
For most applications, SameSite=Lax (or Strict) is sufficient and eliminates CSRF without any other measures.
CSRF Tokens β The Classic Defense
Before SameSite was widely supported, CSRF tokens were the standard defense. They're still appropriate for defense in depth.
Synchronizer Token Pattern: the server generates a unique, secret token per session. Every state-changing form includes the token. On submission, the server validates it. An attacker on evil.com cannot read the token (same-origin policy blocks cross-origin reads), so they can't forge a valid request.
// ASP.NET Core generates and validates CSRF tokens automatically in Razor Pages
// For controllers, use the attribute:
[ApiController]
[AutoValidateAntiforgeryToken] // Applies to all non-GET actions
public class AccountController : Controller
{
[HttpPost("transfer")]
[ValidateAntiForgeryToken] // Explicit on a specific action
public async Task<IActionResult> Transfer(TransferRequest request)
{
// If CSRF token is missing or invalid, returns 400 automatically
await _bankService.TransferAsync(request);
return Ok();
}
}In Razor views, @Html.AntiForgeryToken() or <form asp-controller="Account" asp-action="Transfer"> automatically injects a hidden field:
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8N..." />For AJAX requests, include the token in a request header:
// Read the token from the cookie or a meta tag
const token = document.querySelector('meta[name="csrf-token"]')?.content;
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': token
},
body: JSON.stringify({ to: 'recipient', amount: 100 })
});// Configure ASP.NET Core to read token from header
services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
});Double Submit Cookie Pattern
When you can't share session state (stateless APIs behind load balancers), use the Double Submit Cookie pattern:
- Server sets a random value in a cookie:
csrf-token=abc123 - Client reads this cookie (it must be readable β not HttpOnly) and sends it as a request header or body parameter
- Server verifies the cookie value matches the submitted value
An attacker on evil.com cannot read your cookie (same-origin policy), so they cannot include the matching value. The check proves the request originated from your frontend.
// Middleware: set CSRF cookie on first request
app.Use(async (context, next) =>
{
if (!context.Request.Cookies.ContainsKey("csrf-token"))
{
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
context.Response.Cookies.Append("csrf-token", token, new CookieOptions
{
HttpOnly = false, // Must be readable by JavaScript
Secure = true,
SameSite = SameSiteMode.Strict
});
}
await next();
});JWT in Authorization Header β Not Vulnerable to CSRF
This is an important nuance developers often miss:
If your API uses JWT in the Authorization: Bearer <token> header (not a cookie), it is not vulnerable to CSRF.
Here's why: the browser never automatically adds custom headers to cross-origin requests. When evil.com submits a form to your API, the request arrives without an Authorization header. Your API sees no token and returns 401.
CSRF is exclusively a cookie problem. APIs that authenticate via:
Authorization: Bearerheader- API keys in custom headers
- Basic Auth in the header
...are immune to CSRF. You only need CSRF protection when cookies are your authentication mechanism.
CORS as a Partial Defense
CORS (Cross-Origin Resource Sharing) controls which origins can make cross-origin requests from JavaScript. A strict CORS policy prevents evil.com's JavaScript from making authenticated fetch/XHR calls to your API and reading the response.
However, CORS does not block cross-origin form submissions. HTML forms are not subject to CORS β they can POST to any origin (the response is just not readable). So CORS alone does not protect against CSRF.
// Strict CORS β only allow your frontend origin
builder.Services.AddCors(options =>
{
options.AddPolicy("Frontend", policy =>
policy.WithOrigins("https://app.example.com")
.AllowCredentials()
.AllowedMethods(["GET", "POST", "PUT", "DELETE"])
);
});CORS + SameSite cookies together provide strong protection. Neither alone is sufficient for cookie-based auth.
What Does NOT Protect Against CSRF
- HTTPS β encryption protects in-transit data but doesn't stop the browser from sending cookies cross-origin
- Checking the Referer header β can be spoofed, can be suppressed by privacy settings, unreliable
- POST-only endpoints β forms can POST. Method alone provides no protection
- Secret query parameters β query strings are visible in server logs and browser history
Defense Recommendation
For modern applications:
- Use
SameSite=LaxorSameSite=Stricton all session/auth cookies β this alone prevents most CSRF - Add CSRF tokens for defense in depth in Razor applications
- If using JWT in Authorization headers, no CSRF protection is needed for those endpoints
- Pair with strict CORS to block cross-origin JS reads
If you're building a new API that uses JWT in the Authorization header, CSRF is not your concern. If you have a Razor/MVC app or SPAs that use cookie-based auth, use SameSite + ASP.NET Core's antiforgery.
Enjoyed this article?
Explore the Security & Compliance learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.