JWT Authentication: A Tale of Two Tokens

Photo by Dan Nelson on Unsplash

Authentication is a much debated topic in application development. Many techniques exist to handle verifying users, each with their own drawbacks and advantages, as well as potential implementation pitfalls that can lead to crippling security flaws.

In recent years, using tokens, specifically JSON Web Tokens (JWTs), to authenticate users has become a popular technique as they can be used across different platforms and services. One of the most important questions when working with JWTs is how to store and access them. In the upcoming sections I will explain how to use a combination of two tokens, memory, and cookies to implement a JWT authentication system using Node, Express, React, and Sequelize.

Token Storage

Local storage is incredibly simple to use and requires little overhead to get up and running, however it is also highly susceptible. One issue is that information stored in local storage is prone to Cross-site Scripting (XSS) attacks. An XSS attack would allow a malicious party to steal information in local storage, which would include authentication tokens. Once an attacker has a token, they can make requests to the corresponding API with the token and impersonate users, which could be a massive breach of security depending on the application.

What about cookies? Unfortunately, normal cookies are also vulnerable to XSS attacks. However, we could instead use HttpOnly cookies, which cannot be accessed by Javascript because they are only used in HTTP requests. Though this is definitely an improvement, we now run into a different problem, Cross-site Request Forgery (CSRF) attacks. A CSRF attack allows an attacker to hijack requests to the API as if they were a user, which would include the HttpOnly cookie with the request, allowing access to restricted resources. Fortunately, there are ways to mitigate the effectiveness of CSRF attacks that make them a good option.

Finally, let us consider storing tokens in memory. Tokens stored in memory are also vulnerable to XSS attacks; however, it would require a more sophisticated attack tailored to your application rather than a general attack that targets local storage. A bigger problem with memory is that the token is not stored anywhere on the browser. This means if a user logs in to our application, they will only be logged in on that instance of the application. If they open a new tab or window with our application they will not be logged in. Though this is not a security issue, it is certainly a user experience problem, but it can be addressed by using a second token.

Ultimately, the best measure we can take to ensure security is to clean up any potential XSS vulnerabilities in our applications, but that can be very difficult and mistakes can slip through relatively easy. If we could guarantee that we did not have any XSS vulnerabilities, we could employ local storage, but that is a difficult guarantee to make.

For the remainder of this article, I will use a combination of memory and cookies to implement JWT authentication.

Two Tokens

The access token will be passed to every API request and will be responsible for authenticating a user and granting access to restricted resources. When a user logs in to our application, they will be granted an access token, which they will store on the frontend in memory. The access token will determine whether or not a user is logged in to our application. In order to persist this log in across different instances, we’ll use the refresh token.

The refresh token, like the access token, serves to identify a user. However, the refresh token is never used for API requests, it is only used to request new access tokens if an old one is expired or a user opens a new instance of our app. To fill this purpose, the refresh token needs to live in the browser, which means we will need either local storage or cookies. I will use an HttpOnly cookie to store the refresh token. To add some protection against CSRF attacks, the cookie containing the refresh token will only be sent on requests to a specific route, responsible for obtaining a new access token.

Token Expiration

Since the access token is the one responsible for accessing restricted resources, its lifespan should be very short, somewhere between 10 and 30 minutes. By doing this, if an access token is ever exposed, it will only work for a brief amount of time before needing a refresh.

On the other hand, a refresh token, responsible for persisting logins and fetching new access tokens, should have a longer lifespan. The exact lifespan of a refresh token is application dependent, financial apps may not want to persist a login for more than a day (or even an hour), whereas an e-commerce or social media app may want to persist logins for weeks.

Logging Out

Data Flow

This will give the user a fresh access token and set a cookie with the refresh token. But what if a user had previously logged in and is now restoring a session? The diagram will look nearly identical. In this case the client will make a request to the refresh route, which will include the cookie containing their refresh token. If the refresh token exists and is valid, a new access token will be sent back to the client. In addition, whenever a new access token is returned, a new refresh token will also be generated to continue persisting the login. If the refresh token did not exist or was invalid, the client will receive an error and redirect to login.

For an API request, the process is slightly more complicated. First, the client makes a request to the API using the access token. If the access token is valid, the client receives the expected response. If the access token is invalid, the client will receive an authentication error from the server. The client will then attempt to obtain a new access token and retry the request. If obtaining an access token did not work, the client redirects to login. Below you can see an example of this process.

To avoid any infinite loops, it is important to not attempt to refresh again after the first refresh fails.

Invalidating Tokens

If the access token is stolen, someone can access restricted resources. Fortunately, due to the short lifespan of the access token, they can only do so for a few minutes. However, if someone manages to steal or hijack a refresh token, they could generate access tokens for as long as the refresh token is valid.

In order to alleviate this, users will have an additional piece of information stored in the database, their token version. Each refresh token will contain information about a user’s token version and will only be valid if the two numbers match. If a user feels that their security has been compromised, they can invalidate all their tokens, which increases their token version number, thereby making all previous refresh tokens invalid. If someone tries to refresh with an outdated token, they will be forcefully logged out. While this is not a perfect solution, it is a good starting point for improvements.

Implementation

I won’t go over all the code here, but I’ll bring up some key players. First, let’s start with the user model.

This is a simple model that holds a username and a hashed password. The most noteworthy field is tokenVersion, which is responsible for checking the validity of refresh tokens.

The user model will have some important class and instance methods to interact with tokens, which you can see in the repo.

Below you will be able to see the backend token generation logic. To better observe and test the process of refreshing and obtaining tokens, the lifespan of both the access and refresh tokens are very short. In practice it would be better to use the commented out versions. An important note here is that the cookie holding the refresh token should have an expiry time equal to that of the token itself to prevent keeping old cookies on the client.

On the frontend, I am using Redux to store the access token (and a user’s information). There are other ways to store the access token in memory, but I chose Redux as it easily allows us to visualize how the access token is used and changes by using Redux logging and dev-tools.

The most important part of the frontend code is setting up Axios. Axios allows you to define functions that run before and after a request using interceptors. We will set up one interceptor for the requests and one for the responses.

For the request interceptor, we are just attaching the access token to every request as the authorization header if the token exists. If a particular route does not require authorization Axios will still attempt to include the token, but it wont have any effect on the result.

The response interceptor is more complicated as it has to incorporate the refreshing logic. First, if the response came back normally, we simply return the response (which will then send it to wherever the call was made). If the response has an error, we’ll first check if the error came from a refresh attempt. To avoid infinite refresh attempts, we’ll make a logout request to clear any refresh token and then clear the Redux state. If the error did not come from a refresh request, we’ll attempt a refresh. If the refresh is successful, the original query will be retried (on line 41).

To see more on how each of these pieces work together and the full frontend/backend flows, I highly recommend reading through the full code and trying it out in the browser.

Conclusion

Resources

For a deeper look into XSS and CSRF attacks:

For more on client side storage: