The Ultimate Guide to handling JWTs on frontend clients (GraphQL)

JWTs (JSON Web Token, pronounced 'jot') are becoming a popular way of handling auth. This post aims to demystify what a JWT is, discuss its pros/cons and cover best practices in implementing JWT on the client-side, keeping security in mind. Although, we’ve worked on the examples with a GraphQL clients, but the concepts apply to any frontend client.


Introduction: What is a JWT?

For a detailed, technical description of JWTs refer to this article.

For the purposes of auth, a JWT is a token that is issued by the server. The token has a JSON payload that contains information specific to the user. This token can be used by clients when talking to APIs (by sending it along as an HTTP header) so that the APIs can identify the user represented by the token, and take user specific action.

But can’t a client just create a random JSON payload an impersonate a user?

Good question! That’s why a JWT also contains a signature. This signature is created by the server that issued the token (let’s say your login endpoint) and any other server that receives this token can independently verify the signature to ensure that the JSON payload was not tampered with, and has information that was issued by a legitimate source.

But if I have a valid and signed JWT and someone steals it from the client, can’t they use my JWT forever?

Yes! If a JWT is stolen, then the thief can can keep using the JWT. An API that accepts JWTs does an independent verification without depending on the JWT source so the API server has no way of knowing if this was a stolen token! This is why JWTs have an expiry value. And these values are kept short. Common practice is to keep it around 15 minutes, so that any leaked JWTs will cease to be valid fairly quickly. But also, make sure that JWTs don’t get leaked.

These 2 facts result in almost all the peculiarities about handling JWTs! The fact that JWTs shouldn’t get stolen and that they need to have short expiry times in case they do get stolen.

That’s why it’s also really important not to store JWT on the client, say via cookies or localstorage. Doing so you make your app vulnerable to CSRF & XSS attacks, by malicious forms or scripts to use or steal your token lying around in cookies or localstorage.

So does a JWT have a specific kind of structure? What does it look like?

A JWT looks something like this, when it's serialized:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o

If you decode that base64, you'll get JSON in 3 important parts:  header, payload and signature.

The 3 parts of a JWT (based on image taken from jwt.io)

The serialized form is in the following format:

[ base64UrlEncode(header) ] . [ base64UrlEncode(payload) ] . [signature ]

A JWT is not encrypted. It is based64 encoded and signed. So anyone can decode the token and use its data. A JWT's signature is used to verify that it is in fact from a legitimate source.

Here is the diagram of how a JWT is issued(/login) and then used to make an API call to another service( /api) in a nutshell:

A workflow of how a JWT is issued and then used

Ugh! This seems complicated. Why shouldn’t I stick to good old session tokens?

This is a painful discussion on the Internet. Our short (and opinionated answer) is that backend developers like using JWTs because a) microservices b) not needing a centralized token database.

In a microservices setup, each microservice can independently verify that a token received from a client is valid. The microservice can further decode the token and extract relevant information without needing to have access to a centralized token database.

This is why API developers like JWTs, and we (on the client-side) need to figure out how to use it. However, if you can get away with a session token issued by your favourite monolithic framework, you’re totally good to go and probably don’t need JWTs!


Basics: Login

Now that we have a basic understanding what a JWT is, let's create a simple login flow and extract the JWT. This is what we want to achieve:

A login flow for getting a JWT

So how do we start?

The login process doesn’t really change from what you'd usually do. For example, here’s a login form that submits a username/password to an auth endpoint and grabs the JWT token from the response. This could be login with an external provider, an OAuth or OAuth2 step. It really doesn't matter, as long as the client finally gets a JWT token in the response of the final login success step.

First, we'll build a simple login form to send the username and password to our login server. The server will issue JWT token and we will store it in memory. In this tutorial we won’t focus on auth server backend, but you're welcome to check it out in example repo for this blogpost.

This is what the handleSubmit handler for a login button might look like:

The login API returns a token and then we pass this token to a login function from /utils/auth where we can decide what to do with the token once we have it.

So we’ve got the token, now where do we store this token?

We need to save our JWT token somewhere, so that we can forward it to our API as a header. You might be tempted to persist it in localstorage; don’t do it! This is prone to XSS attacks.

What about saving it in a cookie?

Creating cookies on the client to save the JWT will also be prone to XSS. If it can be read on the client from Javascript outside of your app - it can be stolen. You might think an HttpOnly cookie (created by the server instead of the client) will help, but cookies are vulnerable to CSRF attacks. It is important to note that HttpOnly and sensible CORS policies cannot prevent CSRF form-submit attacks and using cookies require a proper CSRF mitigation strategy.

Note that the new SameSite cookie spec which is getting increased support in most browsers will make Cookie based approaches safe from CSRF attacks. It might not be a solution if your Auth and API servers are hosted on different domains, but it should work really well otherwise!

Where do we save it then?

Well for now, we will store it in memory (and we’ll come to persisting sessions in the following section).

As you can see here we store token in memory. Yes, the token will be nullified when the user switches between tabs, but we will deal with that later. I will also explain why I have noRedirect flag as well as jwt_token_expiry

Ok! Now that we have the token what can we do with it?

  • Using in our API client to pass it as a header to every API call
  • Check if a user is logged in by seeing if the JWT variable is set.
  • Optionally, we can even decode the JWT on the client to access data in the payload. Let's say we need the user-id or the username on the client, which we can extract from the JWT.

How do we check if our user is logged in?

We check in our utils/auth if the token variable is set and if it isn't - redirect to login page.


Basics: Client setup

Now it’s time to set up our GraphQL client. The idea is to get the token from the variable we set, and if it’s there, we pass it to our GraphQL client.

Using the JWT in a GraphQL client

Assuming your GraphQL API accepts a JWT auth token as an Authorization header, all you need to do is setup your client to set an HTTP header by using the JWT token from the variable.

Here's what a setup with the Apollo GraphQL client using an ApolloLink middleware.

As you can see from the code, whenever there is a token, it’s passed as a header to every request.

But what will happen if there is no token?

It depends on the flow in your application. Let's say you redirect the user back to the login page:

else {
 Router.push('/login')
}

What happens if a token expires as we're using it?

Let's say our token is only valid for 15 minutes. In this case we'll probably get an  error from our API denying our request (let's say a 401: Unauthorized error). Remember that every service that knows how to use a JWT can independently verify it and check whether it has expired or not.

Let’s add error handling to our app to handle this case. We'll write code that will run for every API response and check the error. When we receive the token expired/invalid error from our API, we trigger the logout or the redirect to login workflow.

Here's what the code looks like if we're using the Apollo client:

You may notice that this will result in a fairly sucky user experience. The user will keep getting asked to re-authenticate every time the token expires. This is why apps implement a silent refresh workflow that keeps refreshing the JWT token in the background. More on this in the next sections below!


Basics: Logout

With JWTs, a "logout" is simply deleting the token on the client side so that it can't be used for subsequent API calls.

So...is there no /logout API call at all?

A logout endpoint is not really required, because any microservice that accepts your JWTs will keep accepting it. If your auth server deletes the JWT, it won't matter because the other services will keep accepting it anyway (since the whole point of JWTs was to not require centralised coordination).

The token is still valid and can be used. What if I need to ensure that the token cannot be used ever again?

This is why keeping JWT expiry values to a small value is important. And this is why ensuring that your JWTs don't get stolen is even more important. The token is valid (even after you delete it on the client), but only for short period to reduce the probability of it being used maliciously.

In addition, you can add a blacklisting workflow to your JWTs. In this case, you can have a /logout API call and your auth server puts the tokens in a "invalid list". However, all the API services that consume the JWT now need to add an additional step to their JWT verification to check with the centralised "blacklist". This introduces central state again, and brings us back to what we had before using JWTs at all.

Doesn’t blacklisting negate the benefit of JWT not needing any central storage?

In a way it does. It’s an optional precaution that you can take if you are worried that your token can get stolen and misused, but it also increases the amount of verification that has to be done. As you can imagine, this had led to much gnashing of teeth on the internet.

What will happen if I am logged in on different tabs?

One way of solving this is by introducing a global event listener on localstorage. Whenever we update this logout key in localstorage on one tab, the listener will fire on the other tabs and trigger a "logout" too and redirect users to the login screen.

These are the 2 things we now need to do on logout:

  1. Nullify the token
  2. Set logout item in local storage

In that case whenever you log out from one tab, event listener will fire in all other tabs and redirect them to login screen.

This works across tabs. But how do I "force logout" of all sessions on different devices?!

We cover this topic in a little more detail in a section later on: Force logout.


Silent refresh

There are 2 major problems that users of our JWT based app will still face:

  1. Given our short expiry times on the JWTs, the user will be logged out every 15 minutes. This would be a fairly terrible experience. Ideally, we'd probably want our user to be logged in for a long time.
  2. If a user closes their app and opens it again, they'll need to login again. Their session is not persisted because we're not saving the JWT token on the client anywhere.

To solve this problem, most JWT providers, provide a refresh token. A refresh token has 2 properties:

  1. It can be used to make an API call (say, /refresh_token) to fetch a new JWT token before the previous JWT expires.
  2. It can be safely persisted across sessions on the client!

How does a refresh token work?

This token is issued as part of authentication process along with the JWT. The auth server should saves this refresh token and associates it to a particular user in its own database, so that it can handle the renewing JWT logic.

On the client, before the previous JWT token expires, we wire up our app to make a /refresh_token endpoint and grab a new JWT.

How is a refresh token safely persisted on the client?!

The refresh token is sent by the auth server to the client as an HttpOnly cookie and is automatically sent by the browser in a /refresh_token API call.

Because client side Javascript can't read or steal an HttpOnly cookie, this is a little better at mitigating XSS than persisting it as a normal cookie or in localstorage.

This is safe from CSRF attacks, because even though a form submit attack can make a /refresh_token API call, the attacker cannot get the new JWT token value that is returned.

To recap, this is how we're thinking about what would be the best way of persisting a JWT based session:👇

Persisting JWT token in localstorage (prone to XSS) < Persisting JWT token in an HttpOnly cookie (prone to CSRF, a little bit better for XSS) < Persisting refresh token in an HttpOnly cookie (safe from CSRF, a little bit better for XSS).

Note that while this method is not resilient to serious XSS attacks, coupled with the usual XSS mitigation techniques, an HttpOnly cookie is a recommended way persisting session related information. But by persisting our session indirectly via a refresh token, we prevent a direct CSRF vulnerability we would have had with a JWT token.

So what does the new "login" process look like?

Nothing much changes, except that a refresh token gets sent along with the JWT. Let's take a look a diagram of login process again, but now with refresh_token functionality:

Login with refresh token

  1. The user logs in with a login API call.
  2. Server generates JWT Token and refresh_token
  3. Server sets a HttpOnly cookie with refresh_token. jwt_token and jwt_token_expiry are returned back to the client as a JSON payload.
  4. The jwt_token is stored in memory.
  5. A countdown to a future silent refresh is started based on jwt_token_expiry

And now, what does the silent refresh look like?

Silent refresh workflow

Here's what happens:

  1. Call /refresh_token endpoint
  2. Server will read httpOnly cookie and if it finds a valid refresh_token, then...
  3. ...the server returns a new jwt_token and jwt_token_expiry to the client and also sets a new refresh token cookie via  Set-Cookie header.

Persisting sessions

Now that we can make sure that our users don't keep getting logged out, let's turn our attention to the second problem of persisting sessions.

You'll notice that if users close your app and open it again (lets say by closing the browser tab and re-opening it), they'll be asked to login again.

Apps usually ask their users if they want to "stay logged in" across sessions, or by default, keep their users logged in. This is what we'd like to implement as well.

Currently, we can't do this because the JWT is only stored in memory and is not persisted. To recap, head to this section above to see why we can't store JWTs in cookies or in localstorage directly.

So how do we persist sessions securely then?

Refresh tokens! We were able to persist refresh tokens securely and use them for silent refresh (aka renewing our short expiry JWT tokens without asking users to login again). And we can also use them to fetch a new JWT token for a new session! Check out the previous section discussing how refresh tokens are persisted.

Let's say that the user logged out of their current session by closing their browser tab. Now that the user visits the app again, let's see what the flow looks like:

  1. If we see that we don't have a JWT in memory, then we trigger the silent refresh workflow
  2. If the refresh token is still valid (or hasn't been revoked), then we get a new JWT and we're good to go!

Possible error case:

In case our refresh token expires (say the user comes back to the app after a really long time), or gets revoked (because of a "force logout", say) the client will get 401 error for an unauthorized refresh_token. Another case might just be that we don’t have any refresh_token in the first place, in which case we'll also get an error from our /refresh_token endpoint and we will redirect the user to the login screen.

Here's some sample code showing how we would deal with this error handling using a logoutLink


Force logout, aka Logout of all sessions/devices

Now that are users are logged in forever and stay logged in across sessions, there's a new problem that we need to worry about: Force logout or, logging out of all sessions and devices.

The refresh token implementations from the sections above, show us that we can persist sessions and stay logged in.

In this case, a simple implementation of "force logout" is asking the auth server to invalidate all refresh tokens associated for a particular user.

This is primarily an implementation on the auth server backend, and doesn't need any special handling on the client. Apart from a "Force Logout" button on your app perhaps :)


Server side rendering (SSR)

In server side rendering there are additional complexities involved when dealing with JWT tokens.

This is what we want:

  1. The browser makes a request to a app URL
  2. The SSR server renders the page based on the user's identity
  3. The user gets the rendered page and then continues using the app as an SPA (single page app)

How does the SSR server know if the user is logged in?

The browser needs to send some information about the current user's identity to the SSR server. The only way to do this is via a cookie.

Since we've already implemented refresh token workflows via cookies, when we make a request to the SSR server, we need to make sure that the refresh-token is also sent along.

Note: For SSR on authenticated pages, it is vital that that the domain of the auth API (and hence the domain of the refresh_token cookie) is the same as the domain of the SSR server. Otherwise, our cookies won't be sent to the SSR server!

This is what the SSR server does:

  1. Upon receiving a request to render a particular page, the SSR server captures the refresh_token cookie.
  2. The SSR server uses the refresh_token cookie to get a new JWT for the user
  3. The SSR server uses the new JWT token and makes all the authenticated GraphQL requests to fetch the right data

Can the user continue making authenticated API requests once the SSR page has loaded?

Nope, not without some additional fiddling around unfortunately!
Once the SSR server returns the rendered HTML, the only identification left on the browser about the user's identity is the old refresh token cookie that has already been used by the SSR server!

If our app code tries to use this refresh token cookie to fetch a new JWT, this request will fail and the user will get logged out.

To solve this, the SSR server after rendering the page needs to send the latest refresh token cookie, so that the browser can use it!

The entire SSR flow, end to end:


Sample code

Sample code for this blogpost with an end to end working app, with SSR capabilities is available here.

The repository also contains the sample auth backend code (based on the awesome https://github.com/elitan/hasura-backend-plus).


References


Summary

Once you've worked through all the sections above, your app should now have all the capabilities of a modern app, using a JWT and should be secure from the common major security gotchas that JWT implementations have!

Let us know on twitter or in the comments below if you have any questions, suggestions or feedback!


Hasura is an open-source engine that gives you realtime GraphQL APIs on new or existing Postgres databases, with built-in support for stitching custom GraphQL APIs and triggering webhooks on database changes.


PS: We’re hiring!


This post was a collaboration between

Vladimir, Tanmai Gopal

  • Vladimir

    Vladimir

    Dev advocate @Hasura, Google Developer Expert, consultant, worldwide speaker, book author, 3factorRadio podcast host, OSS contributor. I work in Web/Mobile/VR/AR and IoT fields

    More posts by Vladimir.

    Vladimir
  • Tanmai Gopal

    Tanmai Gopal

    Tanmai is the co-founder of hasura.io. He is passionate about making it easier to build things.

    More posts by Tanmai Gopal.

    Tanmai Gopal