When Edge is fetch-ingly Most Correct

"Most people like to dunk on Edge, but I think it's a pretty great browser," my coworker Thomas told me. We were a few hours into attempting to fix a bug that only occurred in Microsoft Edge and I had just been complaining about Microsoft Edge. This is the story of how he turned out to be right, and I turned out to be wrong.

The Bug

When a user clicks an external link to our platform, and they haven't authenticated, we present them with a login page and then, after they log in, we redirect them to the link they had originally clicked. Users on Microsoft Edge were going through this flow, but instead of getting redirected to the page they wanted to go to, they were getting logged out and sent back to the login page.

Needless to say, this is a really frustrating user experience.

It was also a really frustrating debugging experience, because not only was it only happening in Edge, but also it didn't even happen for all links! So linking to some parts of the app was ok, but other pages would fail.

We also discovered a related problem: if the user refreshed the page, then when navigating to one of the un-link-able pages, they'd get kicked out again. And yes, if they were already on one of those pages, the refresh would result in them getting kicked out.

Debugging the Beast

So how do we narrow this down? We know it has to do with authentication, so let's start by taking a look at our login flow.

Login Flow

When a user logs in, our server responds with an authentication token that the web client should store and send in subsequent requests on behalf of the user. This token tells the server that the requests are coming from this user, without the browser having to store the user's email and password.

Our back-end looks for this token as the the value of an Authentication header, so we used to store this token in a persistent cookie and include it as on every request. However, after a security audit, we switched to storing it as an HTTPOnly cookie. This means that the cookie can't be seen by Javascript code, and is intended to mitigate XSS attacks.

This cookie gets sent in every request, and our nginx server will look for it, parse it, and then store the value of that cookie in the Authentication header, so that when our back-end receives the request, it has no idea that the front end code is storing it differently.

However...we still manually set this older Authentication cookie as a session cookie (ie. temporary). On refreshes, this cookie gets cleared, so it doesn't hang around in the user's browser and get exposed any javascript that gets executed.

Sure enough, there's a comment in the code that says:

We still set basic auth on first login. Just in case something goes wrong with this cookie, user can still use site, at least until refresh  

Sounds fishy-- this is exactly what our Microsoft Edge users are experiencing!

Before we go any further, though, we need to take a brief digression into how our app is architected.

Legacy Code

When Vena started, React didn't exist. (I know. It was a horrifying time.) Instead, we used a framework called Backbone.js to architect our app. As time went on, however, the Backbone code started to get...convoluted.

Recently, we've been working on migrating our app to React. We always knew it was going to take time, so when we spun up the first React/Redux portion of the app, we took care to make sure it could live next to the Backbone parts.

We did this with a class called ReactAppLink, which stored some global state and kept the React and Backbone portions in sync. One of the things it handles is our authentication flow; we don't want the user to have to authenticate once for the Backbone pages and then again for the React pages.

So, the spot of code I found above? Where we set basic auth on first login? That ended up calling ReactAppLink and storing the authentication token in memory, and it makes sure to include that token in the Authentication header for subsequent requests. But, once the app is reloaded (and, in Edge, setting window.location is equivalent to a reload in this sense!), then ReactAppLink will no longer have the Authentication token in memory.

API Requests in React Apps

We're using redux-api-middleware to incorporate API requests into our redux data flow. It does this with a special Action type called RSAA with some specific properties, and it takes those actions and uses the fetch API to make requests to the supplied endpoint.

One of the properties it accepts is called credentials; it determines whether or not to include cookies with the API call. Accepted values for this properties are:

  • omit: don't send any credentials/cookies
  • same-origin: send credentials/cookies only if the request is being made to a resource with same origin as our current site
  • include: always send credentials/cookies

The documentation says that the default is omit. I'll be a little pedantic here and say that the documentation is slightly inaccurate; it doesn't set a default value at all and goes straight to fetch. The reason redux-api-middleware says their default is omit us because, According to the fetch standard, fetch's default is omit.

However: in my experimentation, it seems like Chrome/Safari/Firefox/etc. ignore that standard and implemented fetch such that the default is to always send the cookies!

Microsoft Edge actually obeys the standard and doesn't send the cookie if we don't explicitly tell it to, which meant that whenever we made requests from a React portion of our app, we'd get 401 Unauthorized back, and our user would get booted out to the "Authentication Timeout" page.

To verify this, I logged in to our dev environment, opened the console, and ran the following three commands:

fetch("https://dev.vena.io/api/models/")  
  .then(res => console.log(`using "default", response was ${res.status}`))

fetch("https://dev.vena.io/api/models/", {credentials:"same-origin"})  
  .then(res => console.log(`using "same-origin", response was ${res.status}`))

fetch("https://dev.vena.io/api/models/", {credentials:"omit"})  
  .then(res => console.log(`using "omit", response was ${res.status}`))

On Edge, I saw:

using "omit", response was 401  
using "default", response was 401  
using "same-origin", response was 200  

On Chrome, Safari, and Firefox, I saw:

using "omit", response was 401  
using "default", response was 200  
using "same-origin", response was 200  

(since these are promises, the order is non-deterministic, but the important part is that the type of response in the default case is different!)

External Links to Vena

When a user clicks on a link to Vena, we store that initial URL, log them in, and then do window.location = initialUrl. We do this so that external links to Vena still work, even after the user has logged in.

The fun part is that Edge treats this as a reload, so when we do window.location = initialUrl, it clears the cached credentials in ReactAppLink, and then the user gets kicked out.

How did this ever work?

Remember: we set the old-style Authentication header on initial login ("Just in case something goes wrong with this cookie"), so initial the user experience on logging in from Edge actually works fine, and we only get booted from a React app after a reload!

And in Backbone-land, API requests are all made using XMLHttpRequest, and that behaves differently from fetch.

According to the spec, when you call send() on an XMLHttpRequest, the default behavior is to use same-origin (older spelling for same-site) unless the withCredentials flag is set, in which case it uses the value of that flag.

This means that Backbone land was doing the Right Thing by default, and we didn't need to change anything there.

The Fix

The fix turns out to be a one-liner: we already had some custom middleware wrapping redux-api-middleware, so I added a line there like so:

action[RSAA].credentials = action[RSAA].credentials || "same-origin";  

before passing the action on to redux-api-middleware.

Right and Wrong

Thomas and I started this investigation together, but as it progressed into day 2, he had to go work on other stuff. I caught up with him a few days later and filled him in, and his face just lit up. "Aha! I told you so!" he said with a big grin.

So I guess, if I learned one thing from this, it's that Edge really is the superior browser.


Discuss on twitter or medium.