Axios interceptor to refresh JWT token after expiration

Axios interceptor to refresh JWT token after expiration

JSON Web Tokens expire pretty regularly. That's kind of what they are made for. But our API calls shouldn't fail because of it. So we need an interceptor who deals with this.

When working with Vue, I prefer to use JWT for the authentication between my frontend and the Symfony backend. As http client library I use axios. axios provides basically everything I need out of the box, except a way to retry a call.

What we need is an interceptor which caches errors on the API when the token has expired. It should then use the refresh token (also generated on login), call the API to refresh the token and and try exactly the previous API call again.

All responses from axios are promises. Therefore we have to make sure to return a promise back from the interceptor. I use es6-promise as there is no "native" version in Vue yet.

Our process looks the following:

  • A successful response should just be returned
  • In case of an error we check the status code
    • If it's not a 401 status code, we pipe the error back to the services that it can be handeled there
    • If it's a 401 and it was a try to refresh the token, we log the user out
    • If it's a 401 and we get an indication, that the user is locked, we log the user out
    • In any other case we try to get a new token and call the request again with the new token
import axios from 'axios';
import { Promise } from "es6-promise";
import { TokenStorage } from "./token-storage";
import { router } from '../app';

export default () => {

  axios.interceptors.response.use( (response) => {
    // Return a successful response back to the calling service
    return response;
  }, (error) => {
    // Return any error which is not due to authentication back to the calling service
    if (error.response.status !== 401) {
      return new Promise((resolve, reject) => {
        reject(error);
      });
    }

    // Logout user if token refresh didn't work or user is disabled
    if (error.config.url == '/api/token/refresh' || error.response.message == 'Account is disabled.') {
      
      TokenStorage.clear();
      router.push({ name: 'root' });

      return new Promise((resolve, reject) => {
        reject(error);
      });
    }

    // Try request again with new token
    return TokenStorage.getNewToken()
      .then((token) => {

        // New request with new token
        const config = error.config;
        config.headers['Authorization'] = `Bearer ${token}`;

        return new Promise((resolve, reject) => {
          axios.request(config).then(response => {
            resolve(response);
          }).catch((error) => {
            reject(error);
          })
        });

      })
      .catch((error) => {
      	Promise.reject(error);
      });
  });
}

The TokenStorage is a service of mine which stores the tokens and requests new tokens (just a simple API call to a backend service).

For the backend endpoints I use the LexikJWTAuthenticationBundle for the JWT authentication and the JWTRefreshTokenBundle to create a new JWT with a refresh token as soon as the JWT is expired.

Update: I don't think the token storage is that special, but as a few people have asked, here is the token storage service:

import axios, { AxiosRequestConfig } from 'axios';
import { ApiUrlService } from 'shared/services';
import { Promise } from 'es6-promise';

export class TokenStorage {

  private static readonly LOCAL_STORAGE_TOKEN = 'token';
  private static readonly LOCAL_STORAGE_REFRESH_TOKEN = 'refresh_token';

  public static isAuthenticated(): boolean {
    return this.getToken() !== null;
  }

  public static getAuthentication(): AxiosRequestConfig {
    return {
      headers: { 'Authorization': 'Bearer ' + this.getToken() }
    };
  }

  public static getNewToken(): Promise<string> {
    return new Promise((resolve, reject) => {
      axios
        .post(ApiUrlService.refreshToken(), { refresh_token: this.getRefreshToken() })
        .then(response => {

          this.storeToken(response.data.token);
          this.storeRefreshToken(response.data.refresh_token);

          resolve(response.data.token);
        })
        .catch((error) => {
          reject(error);
        });
    });
  }

  public static storeToken(token: string): void {
    localStorage.setItem(TokenStorage.LOCAL_STORAGE_TOKEN, token);
  }

  public static storeRefreshToken(refreshToken: string): void {
    localStorage.setItem(TokenStorage.LOCAL_STORAGE_REFRESH_TOKEN, refreshToken);
  }

  public static clear(): void {
    localStorage.removeItem(TokenStorage.LOCAL_STORAGE_TOKEN);
    localStorage.removeItem(TokenStorage.LOCAL_STORAGE_REFRESH_TOKEN);
  }

  private static getRefreshToken(): string | null {
    return localStorage.getItem(TokenStorage.LOCAL_STORAGE_REFRESH_TOKEN);
  }

  private static getToken(): string | null {
    return localStorage.getItem(TokenStorage.LOCAL_STORAGE_TOKEN);
  }
}

Update 2: Please be aware that storing authentication relevant data in your local storage (or in any storage accessible by Javascript) opens it up for XSS attacks. Make sure to add an additional security layer.