I. JWT Auth + Refresh Tokens in Rails

This is just some code I recently used in my development application in order to add token-based authentication for my api-only rails app. The api-client was to be consumed by a mobile application, so I needed an authentication solution that would keep the user logged in indefinetly and the only way to do this was either using refresh tokens or sliding sessions.

I also needed a way to both blacklist and whitelist tokens based on a unique identifier (jti)

Before trying it out DIY, I considered using:

  • devise-jwt which unfortunately does not support refresh tokens
  • devise_token_auth I ran into issues when it came to the changing headers on request on mobile, disabling this meant users would have to sign in periodically
  • doorkeeper This was pretty close to what I needed, however, it was quite complicated and I considered it wasn’t worth the extra effort of implmeneting OAuth2 (for now)
  • api_guard This was great, almost everything I needed but it didn’t play too nicely with GraphQL and I needed to implement token whitelisting also.

So, since I couldn’t find any widely-used gem to meet my needs; I decided to just go DIY, and the end result works pretty well. And overview of how things works is so:

  • You call on the Jwt::Issuer module to create an access_token and refresh_token pair.
  • You call on the Jwt::Authenticator module to authenticate the access_token get the current_user and the decoeded_token
  • You call on the Jwt::Revoker module to revoke (blacklist/remove whitelist) a token
  • You call on the Jwt::Refresher module to refresh an access_token based on a refresh_token

There are more modules, but you can preview them for yourself.

There are some prequistes you need in order to use this code:

  1. You need to create a blacklisted tokens table like so: rails g model BlacklistedToken jti:string:uniq:index user:belongs_to exp:datetime

  2. If you want to use whitelisting to, create a tokens table like so: rails g model WhitelistedToken jti:string:uniq:index user:belongs_to exp:datetime

  3. Create a refresh tokens table like & model so. Note the table maybe need to have expires_at field if you want to expire the refresh tokens for avoiding abuse of the system. rails g model RefreshToken crypted_token:string:uniq user:belongs_to

class RefreshToken < ApplicationRecord
  belongs_to :user
  before_create :set_crypted_token

  attr_accessor :token

  def self.find_by_token(token)
    crypted_token = Digest::SHA256.hexdigest(token + Jwt::Secret.secret)
    RefreshToken.find_by(crypted_token: crypted_token.hexdigest)
  end

  private

  def set_crypted_token
    self.token = SecureRandom.hex
    self.crypted_token = Digest::SHA256.hexdigest(token + Jwt::Secret.secret)
  end
end
  1. Update the user model to include the associations
has_many :refresh_tokens, dependent: :delete_all
has_many :whitelisted_tokens, dependent: :delete_all
has_many :blacklisted_tokens, dependent: :delete_all

II. JWT Advantages

JWT (JSON Web Tokens) offers numerous benefits when used for authentication and authorization in web and mobile applications:

  • Stateless Authentication: JWTs are self-contained tokens that store user information and access rights. This eliminates the need to store session state on the server, making authentication stateless and scalable. Scalability is a key advantage of JWTs, as they can be easily distributed across multiple servers or microservices, making it easier to integrate with other systems, because the token contains all the necessary information for authentication and authorization. This reduces the need for server-side storage and session management, simplifying the architecture of the application. Any time, the server can validate the token and extract the user information from it, without the need to query a database or maintain session state.

Example of a JWT token:

{
  "user_id": 123,
  "email": "abc@gmail.com",
  "exp": 1640000000,
  "iat": 1640000000,
  "jti": "abc123"
}
  • Efficiency: JWTs are compact and lightweight, making them efficient for transmitting data over the network. The token is encoded in a JSON format, making it easy to parse and read by both servers and clients.

  • Security: JWT utilizes both signed and encrypted methods of encoding. This ensures that data sent over the network is secure and cannot be easily tampered with.

  • Portability: JWTs can be easily transported across networks and used across multiple platforms, from servers to browsers and mobile apps.

  • Suitability for RESTful APIs: JWT is suitable for RESTful architecture because it allows for storing authentication and authorization information in the token, without the need to store state on the server.

  • Server Load Reduction: Since JWT stores user information and access rights in the token, the server does not need to store user session state. This reduces load on the server and facilitates easier system scalability.

  • Ease of Integration: JWT is an open standard and widely supported in the software development community. There are many libraries and frameworks supporting the creation and verification of JWTs across various programming languages.

  • High Customizability: JWT allows you to add any information to its payload, enabling you to customize the token to fit the specific needs of your application.

However, it’s important to note that using JWT requires careful implementation to avoid security issues such as storing too much sensitive information in the token or failing to properly validate the token’s validity.ds, making it easier to integrate with other systems.

III. The Code

  1. authenticator.rb
module Jwt
  module Authenticator
    module_function

    def call(headers:, access_token:)
      token = access_token || Jwt::Authenticator.authenticate_header(
        headers
      )
      raise Jwt::Errors::MissingToken unless token.present?

      decoded_token = Jwt::Decoder.decode!(token)
      user = Jwt::Authenticator.authenticate_user_from_token(decoded_token)
      raise Jwt::Errors::Unauthorized unless user.present?

      [user, decoded_token]
    end

    def authenticate_header(headers)
      headers['Authorization']&.split('Bearer ')&.last
    end

    def authenticate_user_from_token(decoded_token)
      raise Jwt::Errors::InvalidToken unless decoded_token[:jti].present? && decoded_token[:user_id].present?

      user = User.find(decoded_token.fetch(:user_id))
      blacklisted = Jwt::Blacklister.blacklisted?(jti: decoded_token[:jti])
      whitelisted = Jwt::Whitelister.whitelisted?(jti: decoded_token[:jti])
      valid_issued_at = Jwt::Authenticator.valid_issued_at?(user, decoded_token)

      return user if !blacklisted && whitelisted && valid_issued_at
    end

    def valid_issued_at?(decoded_token)
      !user.token_issued_at || decoded_token[:iat] >= user.token_issued_at.to_i
    end

    module Helpers
      extend ActiveSupport::Concern

      def logout!(user:, decoded_token:)
        Jwt::Revoker.revoke(
          decoded_token: decoded_token,
          user: user
        )
      end
    end
  end
end
  1. blacklister.rb
module Jwt
  module Blacklister
    module_function

    def blacklist!(jti:, exp:, user:)
      user.blacklisted_tokens.create!(
        jti: jti,
        exp: Time.at(exp)
      )
    end

    def blacklisted?(jti:)
      BlacklistedToken.exists?(jti: jti)
    end
  end
end
  1. decoder.rb
module Jwt
  module Decoder
    module_function

    def decode!(access_token, verify: true)
      decoded = JWT.decode(access_token, Jwt::Secret.secret, verify, verify_iat: true)[0]
      raise Jwt::Errors::InvalidToken unless decoded.present?

      decoded.symbolize_keys
    end

    def decode(access_token, verify: true)
      decode!(access_token, verify: verify)
    rescue StandardError
      nil
    end
  end
end
  1. encoder.rb
module Jwt
  module Encoder
    module_function

    def call(user)
      jti = SecureRandom.hex
      exp = Jwt::Encoder.token_expiry
      access_token = JWT.encode(
        {
          user_id: user.id,
          jti: jti,
          iat: Jwt::Encoder.token_issued_at.to_i,
          exp: exp
        },
        Jwt::Secret.secret
      )

      [access_token, jti, exp]
    end

    def token_expiry
      (Jwt::Encoder.token_issued_at + Jwt::Expiry.expiry).to_i
    end

    def token_issued_at
      Time.now
    end
  end
end
  1. expiry.rb
module Jwt
  module Expiry
    module_function

    def expiry
      2.hours
    end
  end
end
  1. issuer.rb
module Jwt
  module Issuer
    module_function

    def call(user)
      access_token, jti, exp = Jwt::Encoder.call(user)
      refresh_token = user.refresh_tokens.create!
      Jwt::Whitelister.whitelist!(
        jti: jti,
        exp: exp,
        user: user
      )

      [access_token, refresh_token.token]
    end
  end
end
  1. refresher.rb
module Jwt
  module Refresher
    module_function

    def refresh!(refresh_token:, decoded_token:, user:)
      raise Jwt::Errors::MissingToken, token: 'refresh' unless refresh_token.present? || decoded_token.nil?

      existing_refresh_token = user.refresh_tokens.find_by_token(
        refresh_token
      )
      raise Jwt::Errors::InvalidToken, token: 'refresh' unless existing_refresh_token.present?

      jti = decoded_token.fetch(:jti)

      new_access_token, new_refresh_token = Jwt::Issuer.call(user)
      existing_refresh_token.destroy!

      Jwt::Blacklister.blacklist!(jti: jti, exp: decoded_token.fetch(:exp), user: user)
      Jwt::Whitelister.remove_whitelist!(jti: jti)

      [new_access_token, new_refresh_token]
    end
  end
end
  1. revoker.rb
module Jwt
  module Revoker
    module_function

    def revoke(decoded_token:, user:)
      jti = decoded_token.fetch(:jti)
      exp = decoded_token.fetch(:exp)

      Jwt::Whitelister.remove_whitelist!(jti: jti)
      Jwt::Blacklister.blacklist!(
        jti: jti,
        exp: exp,
        user: user
      )
    rescue StandardError
      raise Jwt::Errors::InvalidToken
    end
  end
end
  1. secret.rb
module Jwt
  module Secret
    module_function

    def secret
      ENV.fetch('JWT_SECRET')
    end
  end
end
  1. whitelister.rb
module Jwt
  module Whitelister
    module_function

    def whitelist!(jti:, exp:, user:)
      user.whitelisted_tokens.create!(
        jti: jti,
        exp: Time.at(exp)
      )
    end

    def remove_whitelist!(jti:)
      whitelist = WhitelistedToken.find_by(
        jti: jti
      )
      whitelist.destroy if whitelist.present?
    end

    def whitelisted?(jti:)
      WhitelistedToken.exists?(jti: jti)
    end
  end
end
  1. errors.rb
module Jwt
  module Errors
    class Unauthorized < StandardError; end
    class InvalidToken < StandardError; end
    class MissingToken < StandardError; end
  end
end
  1. application_controller.rb
class ApplicationControler < ActionController::API
  before_action :authenticate

  rescue_from Jwt::Errors::Unauthorized, with: :unauthorized
  rescue_from Jwt::Errors::InvalidToken, with: :unauthorized
  rescue_from Jwt::Errors::MissingToken, with: :unauthorized

  private

  def unauthorized
    render json: { error: 'Unauthorized' }, status: :unauthorized
  end

  def authenticate
    current_user, decoded_token = Jwt::Authenticator.call(
       headers: request.headers,
       access_token: params[:access_token] # authenticate from header OR params
     )

     @current_user = current_user
     @decoded_token = decoded_token
  end
end
  1. sessions_controller.rb
class SessionsController < ApplicationController
  include Jwt::Authenticator::Helpers

  skip_before_action :authenticate, only: %i[create]

  def create
    user = User.find_by(email: params[:email])
    raise Jwt::Errors::Unauthorized unless user&.authenticate(params[:password])

    access_token, refresh_token = Jwt::Issuer.call(user)
    render json: { access_token: access_token, refresh_token: refresh_token }
  end

  def destroy
    logout!(user: @current_user, decoded_token: @decoded_token)
    head :no_content
  end

  def refresh
    access_token, refresh_token = Jwt::Refresher.refresh!(
      refresh_token: params[:refresh_token],
      decoded_token: @decoded_token,
      user: @current_user
    )
    render json: { access_token: access_token, refresh_token: refresh_token }
  end
end
  1. Clear out the blacklisted tokens every 24 hours or the expired whitelist tokens
class BlacklistedToken < ApplicationRecord
  scope :expired, -> { where('exp < ?', Time.now) }
end

# lib/tasks/blacklisted_tokens.rake
namespace :blacklisted_tokens do
  desc 'Remove expired blacklisted tokens'
  task expired: :environment do
    BlacklistedToken.expired.destroy_all
  end
end
# config/schedule.rb
every 1.day, at: '12:00 am' do
  runner 'BlacklistedToken.expired.destroy_all'
end
  1. Add the routes
Rails.application.routes.draw do
  post '/login', to: 'sessions#create'
  delete '/logout', to: 'sessions#destroy'
  post '/refresh', to: 'sessions#refresh'
end

IV. JWT questions

  • What is JWT?

    • JWT (JSON Web Token) is an open standard for securely transmitting information between parties as a JSON object. It is commonly used for authentication and authorization in web and mobile applications.
  • How does JWT work?

    • JWT works by encoding user information and access rights into a token that is digitally signed and optionally encrypted. The token can be sent over the network and used to authenticate and authorize users without the need to store session state on the server.
  • How can JWT be implemented in Ruby on Rails?

    • JWT can be implemented in Ruby on Rails by using the jwt gem to encode and decode tokens, and by creating custom modules to handle token authentication, issuing, refreshing, and revoking. The code provided in this tutorial demonstrates how to implement JWT authentication in a Rails application.
  • What are some best practices for using JWT in Rails?

    • Use a secure secret key to sign and verify JWT tokens.
    • Store sensitive information in the token payload with caution.
    • Implement token expiration and refresh mechanisms to enhance security.
    • Use HTTPS to secure the transmission of JWT tokens over the network.
    • Regularly review and update the JWT implementation to address security vulnerabilities.
  • Do we need to implement token revocation in JWT authentication?

    • Implementing token revocation in JWT authentication is recommended to enhance security and prevent unauthorized access. Revoking tokens allows you to invalidate tokens that have been compromised or are no longer needed, reducing the risk of unauthorized access to user accounts.
  • Do we need to store JWT tokens in a database?

    • Storing JWT tokens in a database is not required, as JWT tokens are self-contained and can be verified without the need for server-side storage. However, storing token-related data such as blacklisted tokens or refresh tokens in a database can provide additional security and control over token management.
  • Do we need to implement refresh tokens in JWT authentication?

    • Implementing refresh tokens in JWT authentication is recommended for long-lived sessions and improved security. Refresh tokens allow users to obtain new access tokens without requiring them to re-authenticate, reducing the risk of unauthorized access and enhancing user experience.
  • Are there any security risks associated with JWT authentication?

    • While JWT authentication offers many benefits, there are some security risks to consider, such as token leakage, token tampering, and token expiration. It is important to implement security best practices, such as using secure secret keys, token expiration, and refresh mechanisms, to mitigate these risks and ensure the security of your application.
  • Are there any other authentication methods that can be used with Rails APIs?

    • In addition to JWT authentication, Rails APIs can also use other authentication methods such as OAuth, API keys, and session-based authentication. The choice of authentication method depends on the specific requirements of your application, such as security, scalability, and user experience.

IV. Conclusion

This is a pretty simple implementation of JWT authentication in Rails, and it works pretty well for my use case.

References: