CORS - the definitive final actual guide to solving this nightmare

CORS - the definitive final actual guide to solving this nightmare

CORS or Cross-Origin Resource Sharing is the protocol designed to make the internet more secure whilst destroying the mental health of developers the world over on a semi-regular basis.

If you're still reading, I know who you are and I've got bad news - all of those days you’ve spent trying to fix CORS problems? You’re never getting those back. Hopefully this is it, for today I am declaring war on CORS with a definitive guide to what you need to do to fix it up proper. The examples are javascript but the pain and cures are the same.

CORS is awful

CORS is setup like an N-dimensional Rubik’s cube. You change one thing and it makes another thing invalid. Only by balancing the jewels at the exact right positions in an off-world apache sunset can you get all the apps AND the website to work.

To be fair to the creators of CORS, when they designed it smartphones were yet to become ubiquitous and ... how could they foresee that almost every backend would need multiple consumers?

Settings that work for same/different origins, secure and non-secure, and apps

FIRST you've probably already had a stab - so here are the three places to check:

  1. client side it's just making sure the fetch calls are including credentials
  2. server-side the cookie setting code needs to reflect secure / not secure & sameSite policy
  3. responses need to have CORS headers set (unnecessarily complicated - see below) Let's do it..!

1. Client CORS settings

  • fetch needs credentials: 'include' to pass the cookie from the different server, BUT you CANNOT have an origin of '*' server-side
  • make sure the credentials:include is OUTSIDE of the headers section
  res = await fetch(url, {
    method: 'GET',
    headers: {"Content-Type": "application/json; charset=utf-8"},
    credentials: 'include'
  });

2. CORS Cookies

  • Cookies need to be set with SameSite='Lax' for non-secure and Samesite='None' for secure
  • Cookies also need secure:true or false to match
  // Create JWT with user details
  const token = jwt.sign(user, env.JWT_SESSION_KEY, { expiresIn: 8035200 });
  // Set CORS depending on local or production
  cookies.set('session_jwt', token, {
    httpOnly: true,
    sameSite: (env.NODE_ENV=="production") ? 'None' : 'Lax',
    secure: (env.NODE_ENV=="production") ? true : false,
    path: '/',
    maxAge: 3 * (31 * 24 * 60 * 60) // 3 months in secs
  });

3. CORS at server-side (responding to requests)

  • Ironically, you can only use Allow-Origin:'*' if you're using the same origin(!) or don't want to set cookies. As soon as you add credentials:'include' to your fetch calls this will break!
  • Annoyingly, you also cannot list acceptable origins! (the original design for this was so bad...) so you have to mirror the calling origin as if it is the only acceptable one
  • You MUST handle pre-flight with the verb OPTIONS
  • You need to set Allow-Credentials if you want working cookies

  // CORS HEADERS
  // Change Allow-Origin depending on the origin of the caller
  var origin = event.request.headers.get('Origin');
  origin = (allowedOrigins.includes(origin)) ? origin : 'null';
  response.headers.set('Access-Control-Allow-Origin', origin);
  response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, Credentials, Origin, Methods');
  response.headers.set('Access-Control-Allow-Credentials', 'true');
  // Handle CORS preflight requests
  if (event.request.method === 'OPTIONS') {
    return new Response(null, {
      headers: response.headers,
      status: 200
    });
  }
  return response;

Extra: CORS from inside a webview

If you're using a webview (e.g. Capacitor) to build apps from your React / Svelte / Angular app, the built-in fetch used by iOS / Android does NOT handle OPTIONS requests automatically like a proper browser would, so you need to either use a third-party fetch-a-like or with Capacitor you can seamless replace fetch with the Capacitor version by enabling CapacitorHttp in capacitor.config.ts:

  plugins: {
    CapacitorHttp: {
      enabled: true,
    },
  }

That's it! After which you can use "fetch" as normal.