Token-based authentication: A Practical Guide using Rails and React (part 0)

Sameed AtifSameed Atif
6 min read

Before implementing token-based authentication, we need to understand the components of JWT and the common pitfalls developers face while implementing token-based authentication.

Common pitfalls

Where to store JWT in the front end?

Suppose you're building a Rails API + React JS banking application. You decided to use JWT for validating user sessions. You understand that if anyone gets access to the user's JWT then, that malicious user can perform critical actions on the user's behalf. Two common approaches to exploiting token-based authentication are:

  1. XSS (Cross-site scripting): When a malicious script is executed on the user's browser. For example: through a malicious browser plugin.

  2. CSRF (Cross-site request forgery): When a user clicks on a malicious link, that performs an action on the user's behalf.

How to protect JWT against XSS?

Let's suppose, an end-user installed a 3rd party browser extension: "Free coupons". Little does the end user know that the Chrome extension is going to run a script when the user visits "trusted-bank.com". That script will look in the cookie store, session storage and local storage for the JWT and return it to the hacker.

So how do we protect our token against XSS? Well, we will not store it in any of the above-mentioned storage places. The best place to store JWT, to protect it against XSS, is to store it in a encrypted http-only secure cookie store. This way, the token will only be accessible in HTTP requests and secure will ensure that a valid SSL certificate needs to be present, meaning cookies will only be sent through HTTPS. Something like this:

cookies.encrypted[:auth] = {
          value: "#{access_token}",
          httponly: true,
          secure: Rails.env.production?
}

How to protect against CSRF?

Let's suppose, an end-user received a promotion email: "Click here to get coupons". Little does the end user know that when he clicks on that link, it will send a request to https://www.trusted-bank.com/api/v1/empty_my_wallet. If protection measures are not in place, the request will go through.

So how do we protect our users against CSRF? Well, there are multiple ways:

  1. Since we know, that the API consumer is only going to be the frontend website. So, we can simply add restrictions to our CORS configuration to only accept requests from our front-end URL, like so:

     # frozen_string_literal: true
     # Be sure to restart your server when you modify this file.
    
     # Avoid CORS issues when API is called from the frontend app.
     # Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests.
    
     # Read more: https://github.com/cyu/rack-cors
    
     Rails.application.config.middleware.insert_before 0, Rack::Cors do
       allow do
         origins 'https://www.trusted-bank.com'
    
         resource '*',
         headers: :any,
         credentials: true,
         methods: [:get, :post, :put, :patch, :delete, :options, :head]
       end
     end
    
  2. We can also add another encrypted http-only secure. The purpose of this token would be to ensure that the updation or creation request is from a valid client, which is referred to as csrf_token. However, we need to refresh this token after each request send the refreshed token to the client, and also ensure that it is unique for each user.

However, sometimes that is still not enough. So, it's important to include multi-factor authentication for crucial operations (like emptying the bank account).

What should be the JWT payload?

Anyone can decode a JWT. No coding is required. Just visit jwt.io and you can see for yourself. So, it's important to not pass any sensitive information like email address, name, or password in the payload. The payload of the token should be abstract, by that I mean, even if someone malicious user decodes it. The information in the payload is meaningless to that user, for example, it could just be id:1.

It's really important to set an expiration date and an issue time. The expiration time should be a small duration, depending on what an average user session usually is. For example, No one is going to use a bank app for more than 10 to 15 minutes.

Note: The above is just my assumption.

It's also important to pass a strong signature. A sample payload could look something like this:

JWT.encode(
  {
    id: user.id,
    jti: jti,
    iat: DateTime.current,
    exp: 15.minutes.from_now
  },
  Rails.application.secrets.secret_key_base
)

IMPORTANT: Don't use hardcoded signatures like shown above (this is just a simple example). It will become difficult to rotate signatures in a production environment. Pass the signature through the environment variable. Maintain a list of "past signatures" and remove old signatures from that list, once you are sure that all tokens with that signature have expired.

IMPORTANT: For security reasons, it is also important to specify a "signing algorithm". (See here)

A better example would be:

JWT.encode(
  {
    id: user.id,
    jti: jti,
    iat: DateTime.current,
    exp: 15.minutes.from_now
  },
  ENV['CURRENT_SIGNATURE_KEY'],
  ENV['CURRENT_SIGNATURE_ALGO'] # Could be HMAC, RSA, ECDSA
)

Lifecycle of a JWT

What if the user signs out before the token expires? that would leave time for a hacker to perform some malicious action. What if the user is performing a long action and the JWT expires? It would be tedious to do it all over again. It's important to handle the scenarios to give the user a secure and good application experience.

What if the user signs out before the token expires?

When this happens we should blacklist the token. Meaning, when someone tries to make a request using the blacklisted token, it would not be entertained by the server. Meaning, we need to add another maintain list of blacklisted tokens in our backend server and another layer of security to our backend server also to check if the token is blacklisted or not, before entertaining the request.

IMPORTANT: You should not store blacklisted tokens in the database as shown in this four-part article. That could result in unnecessary amount requests to your database server and would be a slow process. It is better to store it in a redis-queue or directly in the web server.

What if the user is performing a long action and the JWT expires?

When this happens we need to issue a new JWT and refresh the user's session to give the user a good application experience. This means three things:

  1. We need a mechanism to check if the session should be refreshed or not.

  2. We need another secure token, which will have an expiry as well. We will call this token a "refresh token". The user needs to sign in after the refresh token expires.

  3. The front end needs to be aware of this token. Meaning, that the token needs to be passed between each request.

The overflow will look something like this:

  1. When the user signs in, the backend issues a JWT and a refresh token to the user.

  2. When the session expires, a request is made to the server to check if the refresh token in the request is valid or not.

  3. If the refresh token is valid, issue a new JWT to the client.

It is important to set a reasonable expiration time (1 hour) for the refresh token as well, otherwise, the JWT will not matter.

Now that we have the basics down. See how to implement JWT authentication in Rails, over here.

Sources

0
Subscribe to my newsletter

Read articles from Sameed Atif directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sameed Atif
Sameed Atif