Preventing CSRF attacks on a Single Page App with REST API

Protecting Single-Page Apps like Angular, React, Vue, or Meteor from CSRF attacks

tl;dr — If your SPA uses a private REST API, use CORS and a CSRF Token header. If your SPA uses a public REST API, use a SameSite Strict cookie for mutating operations (if you only support newer browsers) or separate API security domains (if you support older browsers as well); public API clients just use OAuth Bearer tokens.

The world of web app security is a strange place. It’s a bit like playing whack-a-mole, because one security measure may often introduce a new security hole.

This post walks through the CSRF-vulnerability analysis I did recently for my company, and the thinking that went behind it. In particular, we wanted to ensure that our React-based app is secure from CSRF attacks, even though the backend REST API doesn’t require CSRF tokens.

CSRF

Let’s start with Cross Site Request Forgery (CSRF). This is when a malicious website is able to perform actions on your web app within the context of a logged-in user. This happens because your browser helpfully sends your auth credentials along with the request, which is how the site knows that you’re still logged in. Most of the time, this is an HTTP cookie, but you can also be susceptible with any user agent -managed credentials (such as HTTP basic auth, HTTP digest auth, or even mTLS).

How do you prevent this? Your first stop when dealing with any web app security should be OWASP; sure enough, they have a whole guide to CSRF prevention. The traditional mitigation involves generating a CSRF token on the server and added as a hidden form field during server-side rendering.

But how does this work for a modern web development stack, where you have a javascript “Single Page App” (SPA) frontend backed by a REST API?

On its face, the easiest way to prevent CSRF is to not use cookies to store your auth. No cookies, no CSRF. However, this just opens up a new problem- where do you store your auth now? The most obvious second choice is to use local storage. However, this exposes your web app to Cross Site Scripting attacks (XSS), and is thus not recommended.

XSS

Cross Site Scripting (XSS) is one of the most common and most dangerous attacks online. This effectively enables an attacker to coerce your website to run arbitrary code, usually JavaScript, in the context of another logged-in user.

XSS is particular dangerous if it enables the attacker to access their auth credentials or session directly. The best mitigation again exposing your auth credentials in the first place. To avoid XSS, you want to store your auth in a HttpOnly, Secure cookie. This ensures that JavaScript is unable to read it, and it will only ever be sent over HTTPS.

But this takes us right back to our original CSRF vulnerability.

SOP/CORS

Some people mistakenly believe that the Same Origin Policy (SOP) or Cross-Origin Resource Sharing (CORS) should protect SPAs from CSRF attacks. But even if your SPA uses XHR requests for everything- and thus relies on SOP/CORS — your attackers aren’t limited to XHR. A malicious website hosting a simple HTML form won’t go through a CORS pre-flight check.

This brings up an interesting point. The browser will send the form-submitted POST request with a content type of application/x-www-form-urlencoded (or perhaps multipart/form-data or text/plain). So some sites try to prevent CSRF attacks by using only allowing application/json content types, effectively disabling simple form-based requests. However, this has its own edge cases, such as POST endpoints that don't require a body (e.g., an operation that "favorites" or "stars" an item).

Furthermore, simple form-based requests and XHR requests aren’t the only browser APIs that a malicious site could use. For example, the Web Beacon API has a 5+ year-old bug in Chrome that doesn’t perform CORS preflight requests for application/json data.

CSRF Tokens

So clearly CORS doesn’t prevent CSRF, even with the addition of content-type checks. Let’s revisit the trusty CSRF Tokens.

Obviously, using a hidden form field doesn’t make sense in the context of a REST API. However, there is a popular variant of the CSRF Token approach that uses HTTP headers instead of a form field. The server includes an X-CSRF-Token response header and validates that all mutating API requests include the same as a request header. This is easy enough, and built-in to many frameworks (like gorilla/csrf for golang).

Ok, fair enough. This provides effective CSRF protection for SPAs. But many modern websites want to make their REST API public. You don’t want to burden your customers’ API clients with CSRF tokens and such. So what’s a developer to do?

SameSite Cookies

If you don’t want to require a CSRF Token for your public APIs, but need to need to store your auth in a cookie (to prevent XSS) for your SPA, what other options do we have to prevent CSRF? Recall that CSRF only occurs when a malicious site can trigger a request that will includes your auth cookie. A newer cookie standard introduced “SameSite” cookies:

  • SameSite=Strict - This instructs the browser only send the cookie for same-site requests. This is a tradeoff of security for usability, however, as this means that links from email, corporate intranet, and other websites will effectively require re-login.
  • SameSite=Lax - This relaxes the "strict" mode to allow safe requests like links which perform GET requests to proceed, but doesn't send the cookie on non-safe operations like POST which are CSRF-prone.

This can offer a pretty strong solution. Of course, this only works if your product explicitly only supports newer browsers. Most companies have an official “browser support policy” for their web application, and many use a tool such as Browser Update to automatically notify customers when they require an update.

For example, when I ran the analysis for my company, I determined that we’re protected from CSRF attacks by the SameSite=Strict attribute on our auth cookie in 91.91% of all browsers, 97.45% of all the browsers we notify as outdated, and 100% of browsers that we officially support.

Note that using a “Lax” cookie alone isn’t considered a sufficient solution to CSRF. If the “Strict” option is too challenging for your product’s usability, the recommended approach is to use two cookies: a “lax” cookie for read requests and a “strict” cookie for mutating requests.

Separate Security Domains

If you support older browsers that don’t enforce SameSite cookies and can’t rely on them, your next option is to expose your APIs at two domains with different security requirements.

For example, you could

  • expose your public API at https://api.example.com and configure it to only respect an OAuth Bearer token (sent in the Authorization header)
  • expose the same API at https://example.com/api and configure it to use a (HttpOnly, Secure) auth cookie, CORS, and a CSRF Token header.

Alternatively, you could expose a single API domain that optionally supports either an OAuth Bearer token or a cookie. To do so, you need to

  • Conditionally check for the CSRF Token header if the request was authorized using a cookie.
  • Allow all domains to make CORS requests, if you want to support third-party browser-based API clients.

Even without CORS protection against script-based attacks, the CSRF Token should sufficiently protect you for both script and form -based CSRF attacks.

Resources

There are a lot of great resources out there, written by people who are much more knowledge about web app security than I am.

Originally published at http://codyaray.com on August 16, 2020.

Personal Finance Expert and Investor, Engineering Leader, and Business Strategist

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store