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 anaccess_token
andrefresh_token
pair. - You call on the
Jwt::Authenticator
module to authenticate theaccess_token
get thecurrent_user
and thedecoeded_token
- You call on the
Jwt::Revoker
module to revoke (blacklist/remove whitelist) a token - You call on the
Jwt::Refresher
module to refresh anaccess_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:
You need to create a blacklisted tokens table like so:
rails g model BlacklistedToken jti:string:uniq:index user:belongs_to exp:datetime
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
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
- 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
- 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
- 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
- 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
- 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
- expiry.rb
module Jwt
module Expiry
module_function
def expiry
2.hours
end
end
end
- 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
- 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
- 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
- secret.rb
module Jwt
module Secret
module_function
def secret
ENV.fetch('JWT_SECRET')
end
end
end
- 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
- errors.rb
module Jwt
module Errors
class Unauthorized < StandardError; end
class InvalidToken < StandardError; end
class MissingToken < StandardError; end
end
end
- 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
- 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
- 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
- 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.
- JWT can be implemented in Ruby on Rails by using the
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:
Public comments are closed, but I love hearing from readers. Feel free to contact me with your thoughts.