Skip to main content
ANVISoftware Solutions
Lesson 8 of 14Intermediate16 min

Cross-Site Request Forgery

By the end of this lesson

Protect state-changing requests from being triggered elsewhere.

Browsers attach cookies to a request based on where it is going, not on where it came from. A request to your application carries your application's cookies whether the user typed the address, clicked a link on one of your pages, or loaded a page somewhere else entirely that caused the request to be made.

Cross-site request forgery uses exactly that behaviour. A signed-in employee has a page from another site open, that page causes their browser to issue a request to your application, and the browser attaches the session cookie as it always does. Your server receives a well-formed, authenticated request from a real employee. The employee did not intend it and may not know it happened.

Note what is not needed for this. No stolen cookie, no password, no ability to read your application's responses — cross-origin rules already prevent that last one. Being able to cause the request is sufficient. That is why the defence is about proving a request came from your own pages, and not about identity: the identity was never in question.

Three conditions have to hold at the same time. Remove any one and the request cannot be forged:

A credential the browser sends automatically
Cookie-based sessions are the main case. Basic authentication and Windows integrated authentication behave the same way, because the browser supplies them without the page having to ask.
An endpoint that changes state
Creating an order, changing a delivery address, approving an invoice, adding a user. A read-only endpoint is not a useful target here, because the page that caused the request cannot read the response across origins.
No proof the request came from your application
Without something in the request that is only obtainable from your own pages, the server cannot tell a form on your site from a request caused elsewhere. Both arrive over the same connection with the same cookie.
Program.cs — anti-forgery validation on by default
C#
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews(options =>
{
    // Validate on every POST, PUT, PATCH and DELETE.
    // Opting in per action means one forgotten attribute is one open endpoint.
    options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
});

builder.Services.AddAntiforgery(options =>
{
    options.HeaderName = "X-CSRF-TOKEN";   // for requests issued by script
    options.Cookie.SameSite = SameSiteMode.Lax;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});

builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.HttpOnly = true;                          // script cannot read it
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // HTTPS only
        options.Cookie.SameSite = SameSiteMode.Lax;              // not sent on cross-site subrequests
    });
  • The anti-forgery mechanism issues two linked values: one in a cookie, and one your page must include in the request as a hidden field or a header. A page on another origin can cause the browser to send the cookie. It cannot read your page to obtain the matching value, which is the whole basis of the defence.
  • Registering AutoValidateAntiforgeryTokenAttribute globally makes validated the default for unsafe methods, and an action opts out explicitly with IgnoreAntiforgeryToken. That is the right way round: a forgotten attribute then produces a rejected request rather than an unprotected endpoint.
  • A Razor form using the form tag helper gets the hidden token field automatically. A hand-written HTML form does not, so it needs Html.AntiForgeryToken() inside it. This is the usual explanation for a form that starts returning 400 after somebody rewrote the markup.
  • HeaderName lets script-driven requests carry the token in a header instead of a form field. Read the token from the antiforgery service when rendering the page, and attach it to your fetch calls.
  • The session cookie is marked HttpOnly, Secure and SameSite Lax. HttpOnly keeps page script from reading it, which matters for the cross-site scripting lesson. SameSite is the second layer described below.

SameSite on the session cookie controls whether the browser attaches it to requests that started on another site:

Lax
Sent with top-level navigations to your site, but not with cross-site subrequests such as a background fetch or a form post from another origin. Current browsers treat this as the default when the attribute is absent, and it removes a large part of the exposure on its own.
Strict
Never sent on any cross-site request. The strongest of the three, with a cost you will hear about: a colleague follows a link to your application from a chat message and arrives signed out, because the cookie was not sent with that navigation either.
None
Sent on every cross-site request, which is the old behaviour. Requires Secure. Appropriate only for a cookie that genuinely has to work in a third-party context, which a session cookie for your own application almost never does.
The caveat
SameSite is enforced by the browser, so it protects users whose browsers enforce it, and it says nothing about requests that did not come from a browser at all. Treat it as a strong layer rather than the whole answer, and keep anti-forgery validation on your state-changing endpoints.

Why an API whose clients send a bearer token in a header is in a different position from a cookie-based application:

 Cookie sessionBearer token in a header
How the credential travelsAttached by the browser automatically, on every request to your domainAdded by your own client code, on each request it chooses to make
A cross-site request to your endpointArrives with the session cookie attachedArrives with no Authorization header, because nothing adds one for it
Result with no extra defenceAuthenticated, and acted uponRejected as unauthenticated
Defence needed for this riskAnti-forgery token, plus SameSite on the cookieLargely none. The mechanism the attack relies on is absent
What you take on insteadNothing extra to store in the clientStoring the token in the client, which raises the stakes on cross-site scripting: page script can read whatever your script can read

Put together, the arrangement for a cookie-based application: SameSite Lax or Strict on the session cookie, HttpOnly and Secure alongside it, anti-forgery validation on by default for every state-changing method, and no state changes on GET. Each layer covers a different failure of the others.

For an API whose clients set an Authorization header themselves, this particular risk largely does not arise, and the attention moves to where that token is stored and to the previous lesson. That is a genuine trade rather than a free win — you have removed one class of problem by taking on responsibility for holding a credential in the client.

One honest caveat about mixed designs, which are common in practice. An application that grew a mobile client often ends up accepting either a cookie or a bearer token on the same endpoints. That endpoint has the cookie's exposure, because a request can choose the cookie. Keep the anti-forgery validation on the cookie path rather than removing it on the grounds that the new client does not need it.

Finally, worth saying plainly: this is one of the few security problems where the framework's default configuration does most of the work. The effort goes into not switching it off, and into noticing the endpoint that was added outside the usual pattern.

Summary

  • The browser attaches cookies by destination, so a request caused from another site arrives authenticated
  • It needs three conditions: an automatic credential, a state-changing endpoint, and no proof of origin
  • Anti-forgery tokens supply that proof, because another origin cannot read your page to obtain the matching value
  • SameSite on the session cookie is a strong second layer, and browser-enforced rather than server-enforced
  • An API using a bearer token in a header is largely immune, because nothing adds that header to a cross-site request

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Does the new API need anti-forgery tokens?

Your team is building an API consumed by a React front end that holds an access token and sends it in an Authorization header.

A colleague asks whether anti-forgery tokens are needed. Give your answer, and list what you would check before committing to it.

Show solution

For this risk, mostly no — and the reason is specific. The attack depends on the browser attaching a credential by itself. An Authorization header is added by your own code, so a request caused by another site arrives without it and is rejected as unauthenticated. Nothing needs to prove where the request came from, because the request cannot authenticate in the first place.

Three things to check before relying on that. Does any endpoint also accept a cookie, for the server-rendered admin pages or a legacy client? If so, that endpoint has the cookie's exposure and needs the token. Is the token itself stored in a cookie the browser sends automatically? Then the format is irrelevant and this applies in full. And are there any state-changing GET endpoints, which are a problem in their own right regardless of credential?

The last thing to check is where the token lives in the browser. Moving a credential into reach of page script makes cross-site scripting more costly for you, which is a real trade rather than a technicality. Some teams keep tokens in a server-side session and hand the browser only a cookie, which puts them back in scope for this lesson deliberately, because they judged the other risk higher.

Try it yourself

Audit the state-changing endpoints

List every route in an application you are working on that changes state. For each one, note the HTTP method, the credential it accepts, and whether anti-forgery validation applies.

Then look for the two patterns that stand out.

Show solution

The first pattern is a state change on GET. These tend to be older convenience routes — a deactivate link, a resend action, something added so it could be triggered from an email. Anti-forgery validation does not cover them, so they need to become POST or DELETE before anything else helps.

The second is an endpoint whose protection differs from its neighbours: one action with IgnoreAntiforgeryToken, or a controller that predates the global filter. Find out why. Sometimes there is a real reason, such as a webhook receiver called by another server with no browser involved, and that endpoint needs its own authentication instead, usually a signature over the payload. Sometimes the reason is that somebody was making a test pass.

The audit is worth doing as a list rather than by reading code, because the thing you are looking for is inconsistency. Twenty endpoints protected the same way and one that is not is a pattern that shows up in a table and hides in a codebase.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

What makes cross-site request forgery possible?
Why is an API whose clients send a bearer token in a header largely unaffected by this?

Saved in this browser only.