Authentication using JSON Web Token (JWT) (Vue.js client / Java server)

I recently started developing a web app using Vue.js. The first challenge I faced was to implement a secure way to authenticate users. I didn’t want to rely on a third party solution but figure it out on my own, learning how it works.

After some digging into different possibilities I decided to use a JSON Web Token (JWT) centered approach. The idea behind JWT is that the authentication is stateless. Each token has a defined lifetime. During its lifetime everyone who knows the secret (that was used to sign the token) can validate it. If we have different micro-services and a user sends requests to several of them, the individual service doesn’t need to verify the authenticity using a central authentication service, but can verify the token itself. This saves network traffic and is faster since no database has to be queried.

The most obvious flaw in this approach is that if a token is leaked/stolen the thieve can use the token for the remainder of it’s lifetime to make requests.

If you wonder if JWT is the right choice for you I highly encourage you to read this article. Reading it will give you a better understanding of the short-comings of this implementation.

This post only shows code snippets relevant for the authentication process. The complete (commented) code can be found here: GitHub

General Idea

Since I want to be able to log out a user on the server side, I choose an approach inspired by the way Google does it in their Firebase Auth implementation.

Instead of having one token that is valid till a user logs out, we are using two token:

  • Access Token
  • Refresh Token

The access token is short lived and expires after five minutes. The refresh token is long lived and expires after five days. Once the access token expired, the refresh token can be used to request a new access token.

The nature of the access token is stateless. This means that each service knowing the secret can verify, that the token is valid. Once the access token expired, a request using it will result in a 401 Unauthorized error. The client receiving this error is responsible to handle it, trying to obtain a new access token using the refresh token. This means that only every five minutes the authentication service is hit with a request to obtain a new access token. The server maintains a list of all issued refresh token. So besides just verifying the signature of the refresh token the server also checks that it is a known token. This means that we can log out a user on the server side, by removing its refresh token from the list of know token.

Implementation

Whenever a user registers a new account or logs into an existing account the two token (access/refresh) are generated and send back to the client.

If the client requests a protected route it has to include the access token. The easiest way I found to do this, using axios, is to use interceptors. This interceptors are registered at the start of the application. As the name suggest each request/respond is intercepted and we can add our authentication logic to it. We define an interceptor that will add the access token (in this context called Bearer token) to the header of each request.

  axios.interceptors.request.use(
    config => {
      // Add the access token to each request whos URL starts with the one defined here
      if (config.url.startsWith(AUTHENTICATION_SERVICE_URL)) {
        let token = getAccessToken();
        // If a token is present add it to the header
        if (token) {
          // Add the Authorization header (make sure there is a space (" ") between "Bearer" and the token)
          config.headers.common.Authorization = "Bearer " + token;
        }
        // If no token was found the header does not include the access token. We don't want to deal with this in the client, but have the server tell us that it is missing.
        return config;
      }
    },
    error => Promise.reject(error)
);

If the token is expired, invalid or the client does not include the access token, the server will respond with a 401 Unauthorized error. Since the access token is only valid for five minutes this error is a normal occurrence. For this case we implemented a second interceptor. This interceptor listens for 401 errors. If such an error occurs it tries to request a new access token from the server using the refresh token. If this succeeds the client repeats the exact request that first was rejected by the server. This all happens in the background and the user will not notice this.

If no new access token can be obtained the client can’t request any protected routes and will log the user out.

  // Deal with all responses containing a 401 error to try to refresh the access token if possible
  axios.interceptors.response.use(
    // No special handling of responses needed. We return it as it comes in.
    response => {
      return response;
    },
    // This object is not null if an error occured
    error => {
      // Check if it is a 401 Unauthorized error
      if (error.response.status === 401) {
        // Try to refresh the access token
        return refreshAccessToken()
          .then(result => {
            // Was refreshing the access token successfull?
            if (result === true) {
              // Repeat the request
              return axios({
                method: error.config.method,
                url: error.config.url,
                data: error.config.data
              });
            } else {
              // If the access token could not be refreshed we reject the promise and the code responsible for the request has to handle it.
              return Promise.reject(Error("Unauthorized"));
            }
          })
          .catch(error => Promise.reject(error));
      }
      // No special treatement of any other error
      return Promise.reject(error);
    }
  );

On the server we have a cache that contains all currently valid refresh token. In this example it is an in-memory cache. For a real-world application we would want this to be a persistent data store. This could be a SQL database or a Redis cache.

The idea is that we have a way to remove refresh token from this cache. Once a refresh token is removed a client can’t use it anymore, to request a new access token. The client will get aware of this as soon as it tries to refresh an access token. In this case the client discards the token which in term means that the user is logged out.

This diagram gives a quick overview of the sequence of requests.

Authentication Flow

API Endpoints

The server provides the following endpoints.

/register

Register a new user.

Request

{
  "email": "string",
  "password": "string"
}

Response

{
  "email": "string",
  "id_token": "string",
  "refresh_token": "string"
}

Error

  • 400
    An error was encountered trying to register the user. This could be a technical error on the server side or the given email address is already registered. The response body will contain an error message that might give more details on the cause of the error.

/login

Login an existing user, issuing the required tokens for subsequent requests.

Request

{
  "email": "string",
  "password": "string"
}

Response

{
  "email": "string",
  "id_token": "string",
  "refresh_token": "string"
}

Error

  • 400
    An error was encountered trying to login the user. The cause might be found as a text in the response body.

  • 401 Password wrong or Email not registered. The cause can be found as a text in the response body.

/refresh

Issue a new access token based on the given refresh token.

Request

{
  "email": "string",
  "refresh_token": "string"
}

Response

Empty body

Error

  • 400
    An error was encountered trying to refresh the access token. The cause might be found as a text in the response body.

  • 403
    If the client send an unknown/expired/blacklisted refresh token.

/user

Get the user data like email and name.

Request

Empty body. The user will be identified by the userID which is part of the claims of the access token which is send with each request in the Authentication header.

Response

{
  "email": "string",
  "first_name": "string",
  "last_name": "string",
  "last_login_at": "2019-01-21T16:49:09.350Z"
}

Error

  • 400
    An error was encountered trying to login the user. The cause might be found as a text in the response body.

  • 401
    If the in the Authentication header provided token is expired or invalid

  • 403
    If the in the Authentication header provided access token is missing or malformed and therefore can’t be processed by the server.

Remark: Error 401 and 403 are technically not returned by the /user endpoint itself, but from the middle-ware checking that the user is authenticated before it reaches the logic to retrieve the user data.

Short-comings

  • Local storage is unsafe to store sensitive information

    I am not knowledgeable enough about this but from what I understand all JavaScript code in the client can access the local storage. This is not limited to our application but also third-party code we use (i.e. analytics or advertisement). A safer alternative would be the user of cookies.