Patrik Engborg

JWT and SPA's

I recently set out to build a new REST API, that will primarily communicate with a Single page app (SPA), but might later also be used for a mobile app. Because of this, JWT (Json Web Tokens) seemed like the best bet for authentication. I won't go in to detail what JWT is - you probably know that already. If not, here is an introduction.

I used JWT before, but never felt I actually understood the ins-and-outs of it, or rather how to use it correctly. And I discovered that it seems to be the case with a whole lot of other fellow devs. So with this blog post, I hope I can iron out some common misconceptions and give a few pointers in the right direction, based on insights and findings made during this project.

So lets jump right in - When a successful login attempt is sent from the client to the API, the API generates a token, that will be used to authenticate every subsequent request. This is what is called stateless authentication. The API has no idea who you are and has to be reminded every time. Just like when you speak to an old person.

The other kind of authentication is called stateful. It's used when your backend and frontend co-exist in the same app and your session can be stored in-memory. This admittedly is easier to deal with, but like JFK famously once said:

We choose to go with stateless authentications and do the other things, not because they are easy, but because they are hard.

What a cool guy - I really admire him for that quote. Another thing he often spoke of - how do we store that API-generated token on the client-side, in order to include it for every upcoming request to the API? The most obvious solution, he said, would be to put the token in local storage, and then just use it in an Authorization request header with the value "Bearer <token>".

Shortly after this sloppy claim he got assassinated. We'll never know if that was due to his foolish usage of local storage, but why risk anything yourself by using inadequate security for your app?

A token is really like an entry ticket to your API (and database). And we need to do what we can to prevent this ticket from being exposed (and for ourselves to be targets of assassins.. ok, I will stop now). I wont pretend to be any kind of security (or history) expert, but knowing that XSS (Cross-site scripting) is a possible threat is enough for me to put in some extra effort.

So local storage is out of the question, maybe try a cookie instead? Isn't that very similar to local storage you might think. Well, yes it is - if you set the cookie with javascript. So instead, we should use a server-generated cookie with a httpOnly flag to hide it from the client-side.

A http-only cookie can only be read, created and modified by the server, even though it touches the browser. You can still see it in your browser's devtools though, which is convenient, but it's not actually accessible with javascript. If you type "document.cookie" in the console you won't see any http-only cookies, even if they exist. It's kind of like a cookie in an invisible suite.

Nice, we're on the right track. So the next step is to actually set this cookie. We just learned that the browser can't control http-only cookies in any way, so trying to set a cookie with httpOnly: true with client-side javascript won't do a thing. Which means it has to be done from the server. More specifically, we need to use a Set-Cookie HTTP response header. The MDN web docs describes it this way (and this time the quote is real):

The Set-Cookie HTTP response header is used to send cookies from the server to the user agent, so the user agent can send them back to the server later

Sounds good to me. This is a simplified snippet from my AuthController in the API (AdonisJS framework).

async login({ auth, request, response }) {
  const { email, password } = request.all()
  const data = await auth.attempt(email, password)

  if (data.token) {
    return response.cookie('token', data.token, {
      domain: 'mydomain.com',
      httpOnly: true,
      secure: true,
      sameSite: 'Lax',
      path: '/'
    })
  }

  return data
}

So when a successful login attempt is made, we need to do something with that token. But in order to make it untouchable to the client, we must not return it as a string or a json object, but as a cookie header.

So let's break that code down a bit.

Email and password is what the user typed in as login credentials, and they are extracted from the request and then validated with the auth.attempt() method. If the credentials are fine, a token is generated, and then placed in the response cookie. This cookie also has some options that are critical to get right:

domain: 'yourdomain.com' makes the cookie accessible to this domain, including subdomains. This is crucial since the api and the app is on the same domain, but different subdomains (app.mydomain.com / api.mydomain.com). If this property is omitted, it defaults to the host of the current document URL, but not subdomains.

httpOnly: true is what's mentioned before. This needs to be set to true to prevent client-side javascript from accessing the cookie.

secure: true not required, but strongly recommended. Set this if you use https for both your app and api. If not, you're subject to man-in-the-middle attacks or HTTP spoofing (I know.. all fancy names, eh).

sameSite: 'Lax’ Makes sure this cookie can't be used for cross-origin requests (different domains) and reduces the risk of cross-site request forgery attacks (CSRF). Feels like we're at war, with all these nasty threats, right?

path: '/' Makes the cookie accessible to all of the domain.

And that should be all to securely place this cookie and make it useful for our purpose. There's a whole other bunch of options too, described here, but we won't care about them right now, for the sake of clarity.

Next up was a thing that puzzled me for a bit. This kind of cookie are supposed to be sent right back to the server when you make the next request. It should be included in the request header automatically, without you having to write any code to put it there. Very convenient. But in my case, there was no trace of any cookie being sent back, which just led to the API telling me to bugger off by giving me a 401 response, which means unauthorised.

What now? Was JFK right? Do I need local storage anyway? Luckily no, you just have to make sure to include withCredentials:true as an option for your AJAX request. Now the browser will happily include your token as a cookie header, and the API should then welcome you with a nice 200 response and hopefully send back some data to you.

var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://example.com/', true);
xhr.withCredentials = true;
xhr.send(null);

And that's about it.

So, if this is the right way to do it (which it seems to me, anyway), why do so many (both users, tutorials and frameworks) use the convention to set or expect an Authorization header with a bearer token? This inevitably requires you to the handle that token manually from the client-side, and thus exposes it in an insecure way.

My conclusion is that a bearer token is fine to use if you connect to the API from a more closed environment, such as a native mobile app. But not in a browser, which is more exposed.

Do my above conclusions and recommendations make any sense? Please let me know.

    Replies

    • Frank

      Awesome post!

    Post reply