The Copenhagen Book

Cross-site request forgery (CSRF)

Table of contents

Overview

CSRF attacks allow an attacker to make authenticated requests on behalf of users when credentials are stored in cookies.

When a client makes a cross-origin request, the browser sends a preflight request to check whether the request is allowed (CORS). However, for certain "simple" requests, including form submissions, this step is omitted. And since cookies are automatically included even for cross-origin requests, it allows a malicious actor to make requests as the authenticated user without ever directly stealing the token from any domain. The same-origin policy prohibits cross-origin clients from reading responses by default, but the request still goes through.

For example, if you are signed into bank.com, your session cookie will be sent alongside this form submission even if the form is hosted on a different domain.

<form action="https://bank.com/send-money" method="post">
	<input name="recipient" value="attacker" />
	<input name="value" value="$100" />
	<button>Send money</button>
</form>

This can just be a fetch() request so no user input is required.

const body = new URLSearchParams();
body.set("recipient", "attacker");
body.set("value", "$100");

await fetch("https://bank.com/send-money", {
	method: "POST",
	body
});

Cross-site vs cross-origin

While requests between 2 totally different domains are considered as both cross-site and cross-origin, those between 2 subdomains are not considered cross-site but are considered cross-origin requests. While the name cross-site request forgery implies cross-site requests, you should be strict by default and protect your application from cross-origin attacks as well.

Prevention

CSRF can be prevented by only accepting POST and POST-like requests made by browsers from a trusted origin.

Protection must be implemented for all routes that deal with forms. If your application does not currently use forms, it may still be a good idea to at least check the Origin header to prevent future issues. It's also a generally good idea to only modify resources using POST and POST-like methods (PUT, DELETE, etc).

For the common token-based approach, the token should not be single-use (e.g. a new token for every form submission) as it will break with a single back button. It is also crucial that your pages have a strict cross-origin resource sharing (CORS) policy. If Access-Control-Allow-Credentials is not strict, a malicious site can send a GET request to get an HTML form with a valid CSRF token.

Anti-CSRF tokens

This is a very simple method where each session has a unique CSRF token associated with it.

<form method="post">
	<input name="message" />
	<input type="hidden" name="__csrf" value="<CSRF_TOKEN>" />
	<button>Submit</button>
</form>

Signed double-submit cookies

If storing the token server-side is not an option, using signed double-submit cookies is another approach. This is different from the basic double submit cookie in that the token included in the form is signed with a secret.

A new token is generated and hashed with HMAC SHA-256 using a secret key. Each HMAC must be linked to the user's session. You can alternatively encrypt the token with algorithms like AES.

func generateCSRFToken(sessionId string) (string, []byte) {
	buffer := [10]byte{}
	crypto.rand.Read(buffer)
	csrfToken := base64.StdEncoding.encodeToString(buffer)
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(csrfToken + "." + sessionId))
	csrfTokenHMAC := mac.Sum(nil)
	return csrfToken, csrfTokenHMAC
}

The token is stored as a cookie and the HMAC is embedded in the form. The cookie should have the Secure, HttpOnly, and SameSite attribute. To validate a request, the cookie can be used to verify the signature sent in the form data.

Traditional double-submit cookies

Regular double-submit cookies that aren't signed will still leave you vulnerable if an attacker has access to a subdomain of your application's domain. This would allow them to set their own double-submit cookies.

Origin header

A very simple way to prevent CSRF attacks is to check the Origin header of the request for non-GET requests. This is a relatively new header that includes the request origin. If you rely on this header, it is crucial that your application does not use GET requests for modifying resources.

While the Origin header can be spoofed by using a custom client, the important part is that it can't be done using client-side JavaScript. Users are only vulnerable to CSRF when using a browser.

func handleRequest(w http.ResponseWriter, request *http.Request) {
  	if request.Method != "GET" {
		originHeader := request.Header.Get()
		// You can also compare it against the Host or X-Forwarded-Host header.
		if originHeader != "https://example.com" {
			// Invalid request origin
			w.WriteHeader(403)
			return
		}
  	}
  	// ...
}

The Origin header has been supported by all modern browsers since around 2020, though Chrome and Safari have supported it before that. If the Origin header is not included, do not allow the request.

The Referer header is a similar header introduced before the Origin header. This can be used as a fallback if the Origin header isn't defined.

Session cookies should have a SameSite flag. This flag determines when the browser includes the cookie in requests. SameSite=Lax cookies will only be sent on cross-site requests if the request uses a safe HTTP method (such as GET), while SameSite=Strict cookies will not be sent on any cross site requests. We recommend using Lax as the default as Strict cookies will not be sent when a user accesses your website via an external link.

If you set the value to Lax, it is crucial that your application does not use GET requests for modifying resources. Browser support for the SameSite flag shows it is currently available to 96% of web users. It’s important to note that the flag only protects against cross-site request forgery (not cross-origin request forgery), and generally shouldn’t be your only layer of defense.