Using JWTs as HttpOnly cookies with React.js

Vivek Krishna
8 min readDec 30, 2020

--

JWT ❤ React.js

JWTs are becoming the preferred mode of user authentication and authorization in modern webapps because of a lot of advantages like statelessness, decentralized control, json body support and auto expiry (read more about them here).

Since JWTs are stateless, the token must be available to authenticate users & API calls. Plus, there are lots of security vulnerabilities associated with storing JWTs in client side stores. This article discusses those problems and proposes a solution for mitigating those issues.

Frontend + Backend Setup

The solution outlined in this article was devised based on the following backend and frontend setup.

  1. A backend Authentication Service that authenticates user credentials.
  2. A gateway layer like Nginx that proxies the calls from the frontend to the backend auth service.
  3. A Frontend React.js application which has a Node.js (Express or Next.js) server.
  4. Login calls return an auth token and a refresh token (More on refresh tokens later).

It was all fun and games until XSS arrived…

Since React is a client side scripting framework, it makes sense if you decide to store the access token in cookies or in the local store in order to access the token on demand. But, an attacker can an easily breach the client side stores of a vulnerable application and access their contents, with an XSS style attack. Ben Awad has a great video about how these attacks are done.

There are a lot of solutions to prevent XSS but, the most tried and tested method for overcoming this problem is to use HttpOnly cookies. The HttpOnly flag on a cookie indicates to the browser that the cookie is not accessible from the client. If a client side script attempts to access a HttpOnly cookie, the browser sends an empty string back to the script.

React relies on client side scripts and since HttpOnly cookies are not accessible from the client, it makes it impossible for React to access session cookies on demand. This affects a lot of use cases, which rely on the cookies to get their jobs done.

Use cases affected by HttpOnly cookies

There are 5 major flows that are affected by this problem.

  1. Login
  2. Logout
  3. Refresh
  4. Authenticating API calls
  5. and checking if the user session is expired or not

So, how do we do all that and also make the session cookies HttpOnly? Basically, the solution is to introduce a server and a gateway layer between the client and the backend server, in order to read and access the HttpOnly cookies. The following topics explain how this solution is applied to all the affected use cases in detail.

Login

a pass thru endpoint called /api/login in node, the node endpoint send the token back to the browser as a set-cookie header.
Sequence Diagram for the login flow

The Login flow is pretty straightforward.

  1. We have a pass through node endpoint (/api/login) that calls our backend authentication service.
  2. The Authentication service authenticates the user and sends 2 tokens (Authorization Token and Refresh Token) back in the response to the node endpoint.
  3. The node endpoint parses the token and send the contents of the token plus the set-cookie header with the tokens in it to the browser.
  4. The browser sets the cookie and puts the token contents in the local store.

The set-cookie header contains

  1. The auth/refresh token and
  2. HttpOnly, Secure and SameSite attributes are set to true.

Do not store the entire token in the local store, this defeats the purpose of our solution. Only the contents of the auth token are persisted in the local store. Among the contents is the time-to-live (TTL, token expiry timestamp) of the token. We will use this TTL value to check whether the user session is expired or when we need to refresh the session.

Logout

Either user logs out or session expires, we call /api/logout, it returns a set-cookie header with empty values
Basic Logout Flow without user authentication

The user is logged out either by the browser or by user actions as per the following 2 scenarios.

  • If the user physically clicks on the logout button or
  • If the user leaves the session idle beyond a specified session expiry time.

In both cases we will call the /api/logout endpoint, which will send an empty set-cookie header with the same names as the cookies that were sent in the login flow, with a max-age as 0 or -1 to invalidate the cookie from the browser. Plus, the contents of the local store are also purged from the browser.

Logout flow just deletes the token from the cookie, it does not invalidate the auth token. If the token was intercepted by an attacker, they will still be able to use it till the the TTL (Time-to-live) expires. That is why it is recommended to use TLS (Transport Layer Security) and to set a very short TTL for the auth token and refresh it frequently.

Refresh

What is a refresh token?

The refresh token can be another JWT with a moderately high TTL and the user id baked into it, or, it can be an opaque token. In the latter option, we might have to maintain an association between the user id and the opaque token in the Authentication Service Database. In both cases the user id is used to re-authenticate the user before issuing a new auth token.

What is it used for?

JWTs have an inherent feature where they auto-expire within a specified time frame. Since we use the auth token to check if the user is logged in or not, we need to create new tokens frequently in order to keep the user logged in to their account. So, we use something called a refresh token to get a new auth token, before the existing one expires. This enables us to keep the user logged in for a period, greater than that of the auth token’s expiry time.

Why do we need to refresh the auth token? Why can’t we just have an auth token with a TTL of about 1000 years?

As I mentioned in the logout flow, it is risky to have an auth token with a very large TTL. But, there are use cases for tokens with infinite lifetimes, in that case the onus is on the transportation network to ensure that the token cookies are sent securely when API calls are made. Plus, high TTL tokens are risky when we have to log the user out of all their active sessions. Pls read the Notes section for more info.

But, it is generally adviced to have a token with a short ttl like 1 hour and refresh the token every 30 mins or so. We can have a setTimeout that triggers every 30 mins or if you have a chatty web app that frequently calls backend APIs we can check if the token needs to be refreshed before every API call.

The refresh token flow sends the refresh token to the pass thru node endpoint and it returns a pair of renewed tokens
Refresh Token flow for getting new Auth Tokens

The refresh flow starts from the browser,

  1. The browser periodically checks if the token is about to expire and if a refresh is required.
  2. If a refresh is required, the browser calls the node endpoint called /api/refresh, works similar to the login endpoint.
  3. It calls the backend to re-authenticate the user. After the authentication process finishes, it returns the refreshed tokens back to the node endpoint.
  4. The node endpoint parses the token and send the contents of the token plus the set-cookie header with the tokens in it to the browser.
  5. The browser sets the cookie and puts the token contents in the local store.

Authenticating API Calls

Use the gateway layer to read cookies and set the HTTP authorization header

React can no longer access cookies because they are HttpOnly. So, we will not able to make API calls that require the auth token as an http authorization header. Thus, We need another layer that reads the token from the cookie and sets it as the authorization header.

So, we rely on an interceptor. This interceptor can be a gateway layer like nginx or API gateway etc. This can get the cookie and set the auth header for every call and then proxy the call to the backend service. OR, you can write your own custom interceptor that captures requests going in and out of your backend services.

These calls might return a 401 because the token that was sent in the request is expired. In which case, you might have to trigger the logout flow and log the user out of their expired session.

Is the user logged in?

We might have to program the app to behave differently for logged in users. This is where the values in the local store come into the picture. Since the contents of the token are persisted in the local store, we can read those values periodically to make a decision as to whether the user is logged in or not.

Plus, the local store is purged when the user logs out. So, we can be sure that the contents of the store are from the logged in user and not from previous user sessions.

Bringing all the pieces together

If you like looking at code, I’ve made a couple of small example projects.

  1. Authentication Service → https://github.com/vivekkrishnavk/AuthServ
  2. Next.js app → https://github.com/vivekkrishnavk/next-auth-example

Authentication Service

  • Auth Service is a simple node service with a http server.
  • It has 2 endpoints, /login and /refresh.
  • It runs in port 3001.
Backend Authentication Service Endpoints

Example Next.js Application

  • The example app has 2 pages, login page and my-account
  • The login page calls the /api/login endpoint which in turn calls the /login endpoint in the auth service.
Login, Logout & Refresh endpoints
  • After logging in, the user is routed to the my-account page, which contains 2 buttons. Logout and Refresh.
  • Refresh is usually done in the background, but I’ve added a button here to manually trigger the refresh flow.
  • Logout button calls the /api/logout endpoint, which removes all the token cookies. We will also remove the contents of the local store from the client side after the logout call finishes successfully.

Additional list of affected use cases

What do we do in case the user is blocked or if they reset their password?

In case the user is blocked or if the user resets their password, the user must be logged out of all their active sessions. Since active JWTs will only expire after a set expiry time, we will not be able to immediately invalidate active user sessions. On the other hand, we can prevent the existing sessions from being refreshed, thereby causing them to die out after the expiry time of their respective auth tokens.

How do we prevent existing sessions from being refreshed?

  • If the refresh tokens are stored in a DB, all the active refresh tokens under a given user can be removed to prevent a refresh.
  • If the refresh token is not stored, we can add a flag the user’s account in our authentication service, and block all existing refresh calls from refresh tokens that were created before the flag was set (compare refresh token TTL with the flag creation time). This flag should be present for a time equal to the TTL that was set in the auth token.

--

--