Mitigating CSRF attacks in Single Page Applications

Mihaly Lengyel
Tresorit Engineering
8 min readJan 9, 2020

Cross-Site Request Forgery (or CSRF or XSRF or “sea-surf”) is one of the oldest attacks against web apps. It means that by embedding a form or URL into a malicious site, the attacker can get a request executed on the vulnerable server as if the visitor of their site made it. There are many ways of protecting your applications, with differing levels of complexity. In Single Page Apps (or SPAs) the simplest ones may be the best.

You might think this doesn’t affect you, or that CSRF is dead, but here is how it could hijack your WordPress page (through a membership plugin), your Jenkins (through a Slack plugin) or your router.

What is CSRF?

CSRF is an attack against cookie-based authentication. A site is vulnerable if they check the user’s login state based on a cookie with no (or insufficient) additional checks to see where the request originated. It generally works like this:

  1. The user logs into the vulnerable site as part of their normal use.
  2. The site sets up the session cookies in the browser of the user.
  3. The user visits the malicious site, that has a hidden form embedded.
  4. The hidden form submits a POST request to the vulnerable site or an embedded <img> makes a GET request to it.
  5. The browser executes the request sending the saved credentials along.
  6. The server sees that the request came from the user, and assumes it’s legitimate and executes it, posting pictures of cats on your social media. (or something way more malicious)
CSRF explained

How is it different in SPAs?

A Single Page Application (SPA) is a website, that doesn’t do full page reloads and rewrites the page content instead, to provide a smoother user experience. These include sites built with Angular, React, and other popular frameworks.

The relevant part to CSRF is that you don’t do top-level navigation, don’t submit forms and you handle your server API through XMLHttpRequst or fetch() (or some library built upon these). This is important because top-level navigation is handled differently by same-site cookies and you can rely on CORS if you choose to move your API to a subdomain, enabling you to protect your site more reliably against CSRF — I will explain these in the next section.

You can use the following methods in non-SPA sites as well, but you have to watch out for these differences.

Mitigation

There are several ways to prevent this type of attack, ranging from simply setting up your cookies the right way to generating tokens and adding them in the headers of every request.

1. Checking browser provided headers

As part of CORS support all browsers support sending the Origin header for a cross-origin request (although IE11 doesn’t agree with everyone else on what’s cross-origin), this includes subdomains, too. This header will always be present if the request came from a different origin, meaning you can check it effectively. A similar header is Referer (sic!), that you can check, but it’s unreliable and raises privacy concerns.

The problem is that the Origin header is not always there for requests on the same origin. To solve this, you can move your API to a subdomain and set up CORS. This way all your requests are cross-site and you are prepared to handle them.

2. Setting your cookies up in a more secure way

You can set up your cookies with the SameSite attribute. It takes the values of None, Lax and Strict, with Lax being the default in Chrome after Chrome 80.

None in this context means insecure.

Lax means that cookies will only be sent for requests on the same origin, including top-level navigation (e.g.: clicking a link to your site or refreshing). Since cookies are sent with top-level navigation, then if the attacker can open a window and there is an endpoint accepting GET requests they can try to abuse it. Lax can be a great solution if you don’t have GET endpoints that they can abuse.

Strict means that the site’s cookies will only be attached to requests that originated on your site. This would be great, but it means that users clicking a link to your site will not be logged in, even if they did so previously. This is isn’t great UX, so this isn’t a great solution on its own.

Lax+Strict: if you use multiple cookies, one set to Strict that will be used for sensitive or state-changing operations (e.g.: transferring money, or changing passwords) and a separate one set to Lax that will enable the users to download non-sensitive data. This combines the above two for the best effect, but you will need to manage multiple cookies.

You can read a more in-depth explanation of this here.

3. CSRF Tokens

This is the “classic” way of dealing with CSRF: you add a hidden CSRF token input into forms with the value set to the token you generated and saved on the server (or in an HTTP only cookie), so you can later check it on submission. This solves CSRF issues, if well implemented, but it’s more complex and more prone to errors than the above two.

You can read more about this method and the attack in general on the OWASP cheatsheet.

Which should You use?

These are three very different ways of solving the same problem: which one is the best? OWASP advises using tokens as a primary and the other two as defense-in-depth measures. I’d argue that this could (and maybe should) be done simpler in SPAs.

My recommendations:

  1. Move your API to a subdomain and use CORS for all requests, checking the origin headers. This ensures that the Origin header is present and it’s great for future-proofing your app anyway.
  2. If you can afford to not support some browsers, just use SameSite: Strict cookies, and block requests from unsupported browsers.

How to set up an API subdomain and CORS

Luckily for SPAs, it’s very simple to decouple your API servers and move them to a different subdomain. Likely, you’re already doing some kind of prefixing for your API endpoints to separate them from static content, so it would be as simple as moving your API prefix from the path into the domain part and setting up CORS on your server.

This way the Origin header is always there and you can check it with certainty as no request will originate from the same origin as the API. This also has the added benefit of making it easy to move your static content to a CDN and being able to scale your backend independently of your frontend. This is supported in all browsers SPAs do and it’s mostly a matter of configuration, that will also double as future-proofing your web app in case it goes big.

  1. Set up a subdomain at your domain provider: most of the time it’s free and You can do it in a few clicks.
  2. Configure your app to listen to the new subdomain. This is usually fairly easy and a few simple searches will get you there. For example in node.js with express, you can use express-subdomain. This use-case is their actual example code for the package.
  3. Set up CORS for your application. In node.js there is a package called cors, which also does the header checking for you.
// Add CORS to your API
const cors = require('cors');
const corsOptions = {
origin: 'https://example.com',
optionsSuccessStatus: 200 // for some legacy browsers
}
apiRouter.use(cors(corsOptions));
// Make the API listen on the subdomain
const subdomain = require('express-subdomain');
app.use(subdomain('api', apiRouter));
app.listen(3000);

How to set up Strict cookies

As most SPAs don’t do top-level navigations that complicate SameSite cookies, you could simply use “SameSite: Strict” and be done with it… The only problem is browser support. Using it won’t break in older browsers, but it will not protect them either. It’s not supported by IE11 on Windows 7 or earlier, UC browser and a few others, with a combined market share of less than 10%.

If this is OK, you can simply refuse to serve older browsers using a simple user-agent check. This can be unreliable, but if your users are circumventing it they are at least aware of the problem. Parsing user agents is a whole another topic, that is a bit out of the scope of this. For a quick example use express-useragent or use properly configured regexp from browser-list.

const useragent = require('express-useragent');
// For setting the cookie
res.cookie('session', sessionToken, { maxAge: 900000, httpOnly: true, secure: true, sameSite: true });
// For refusing old browsers
app.use(useragent.express());
app.get('/', function(req, res){
if (!req.isChrome && !req.isFirefox) {
res.status(400).json({error: "BrowserNotSupported"});
}
});

Gotchas of the token method

While the CSRF Token method offers good protection, it can go wrong in a variety of ways, and errors can stay hidden for a while. This is one of the advantages of the above methods: you mess up your config and your app fails, but it won’t be vulnerable. Even an eagerly permissive user agent filter is more easily testable compared to all the details you need to watch out for if you use tokens.

1. Login CSRF

Most implementations forget to do CSRF protection pre-login. This might not sound like a problem, but if someone can log you into their account without your knowledge the site would likely just hand them a log of everything you did until you noticed something wrong: purchases, search history, etc.

See a reported, yet still active login CSRF in New Relic.

2. Vulnerable sub-domains

Your site might not be vulnerable, but maybe a subdomain is (e.g.: where you serve user content). If so, they can set cookies and bypass tokens if it’s a double submit cookie implementation.

Read how OWASP describes it defeats double submit cookies.

3. Leaked tokens

Tokens may leak from your application, through bad logging setup, injected malicious user content, some bug or another.

See how Facebook leaked tokens.

4. Missing checks

Sometimes, somewhere a check goes missing, or someone adds a too permissive exception to the checks to allow some requests through without a token which may have been fine at the time of the original implementation. However it happens, sometimes apps don’t check the CSRF Token. This seems like a dumb mistake, but even big companies make it.

See how WooCommerce missed checks for follow up steps of an import.

5. Tokens not tied to the session/user

This is a fairly common mistake, where you check if the token is valid and not to whom it belongs. Combine this with needing pre-session CSRF Tokens to protect against login CSRF, and you can see where this is getting complicated.

See how it went wrong for PayPal.

TL;DR

There are simpler ways of protecting your SPA against CSRF attacks than the generally recommended tokens. I recommend two solutions:

  1. Move your API to a subdomain and set up CORS. This way all requests are cross-origin, so by setting it up properly you handle CSRF attacks as well.
  2. Set your cookies with SameSite: Strict and refuse serving old browsers. This loses you about 10% of global users, but it might be OK for you.

To me both solutions do better than the “classic” token-based solution: instead of building your complicated solution to hack around an age-old problem, you either align your application with how browsers work and make all requests cross-site (like an attack would) and handle it correctly or you ask the browser to fix their behavior, which new browsers will respect.

Ideally, you should do both of the above (and maybe skip refusing old browsers), and maybe implement CSRF tokens just to be safe and only allow requests that pass all your checks. This is what’s called Defense-in-depth and it’s always the best solution, but this isn’t an ideal world where you have all the time you need to implement every safety measure. Use at least one and then do as much as you can.

--

--

Mihaly Lengyel
Tresorit Engineering

Developer focusing on Web, Web Security, blacksmithing and craft beers.