DEV Community

Cover image for Rails 8 API with Devise-JWT
Chris
Chris

Posted on

Rails 8 API with Devise-JWT

Here, i will be building an API and documenting my steps. if something is wrong, feel free to comment.

What is Devise-JWT?

  • Devise-JWT is a Devise extension that uses JSON Web Tokens (JWTs) to secure API endpoints. When a user logs in, Devise-JWT issues a token that the client must include in requests to prove their identity. Additionally, Devise-JWT generates a JTI(JWT ID)—a unique identifier for each access token, which is stored in the user's database record. This allows the system to track and invalidate tokens by ensuring the token's JTI matches the one currently stored in the database

Setting Up the Project

1. Create a Rails API application with postgresql

rails new backend --api --database=postgresql
cd backend
rails db:create

# Creates below 2 databases
# Created database 'backend_development'
# Created database 'backend_test'
Enter fullscreen mode Exit fullscreen mode

2. Configure Rack-CORS for API only application

Update Gemfile to add/uncomment gem 'rack-cors'

gem 'rack-cors'
Enter fullscreen mode Exit fullscreen mode

Add the following contents to config/initializers/cors.rb file.

#config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://localhost:3000'
    resource(
      '*',
      headers: :any,
      expose: [ "Authorization" ],
      methods: [:get, :patch, :put, :delete, :post, :options, :show]
    )
  end
end

# without expose Authorization, the frontend can't read the JWT
Enter fullscreen mode Exit fullscreen mode

3. Add the following gems and run bundle install.

gem 'devise'
gem 'devise-jwt'
gem 'jsonapi-serializer'

#devise handles Authentication
#devise-jwt adds JWT Token on top of Devise, in turn issues a 
#token on login, revoke it on logout, validate it on every request.
#jsonapi-serializer handles which fields get exposed, formats responses.
Enter fullscreen mode Exit fullscreen mode

4. Configure devise

rails generate devise:install
Enter fullscreen mode Exit fullscreen mode

Devise:install creates the following: JWT Settings, Mailer sender Address, Password length requirements, token expiry

Update for API only apps, navigation format should be empty

#config/initializers/devise.rb
config.navigational_formats = []
Enter fullscreen mode Exit fullscreen mode

5. Create User model

rails generate devise User
rails db:create db:migrate
Enter fullscreen mode Exit fullscreen mode

Generate Controllers to later handle sign-up, login and logout

rails g devise:controllers api/v1 -c sessions registrations
Enter fullscreen mode Exit fullscreen mode

Update Sessions and Registrations Controllers

#app/controllers/users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
  respond_to :json
end
Enter fullscreen mode Exit fullscreen mode
#app/controllers/users/registrations_controller.rb
class Users::RegistrationsController < Devise::RegistrationsController
  respond_to :json
end
Enter fullscreen mode Exit fullscreen mode

Add the routes aliases to override default routes provided by devise in the routes

#config/routes.rb
Rails.application.routes.draw do
  devise_for :users, path: "api/v1", path_names: {
    sign_in: "login",
    sign_out: "logout",
    registration: "signup"
  },
  controllers: {
    sessions: "api/v1/sessions",
    registrations: "api/v1/registrations"
  }
end
Enter fullscreen mode Exit fullscreen mode

6. Configure Devise-JWT
JWT needs to be created with a secret private key that shouldn't be revealed to the public.

Run in terminal to create a secret

rails secret
Enter fullscreen mode Exit fullscreen mode

Run in terminal to open .yml

EDITOR='code --wait' rails credentials:edit
Enter fullscreen mode Exit fullscreen mode

Add devise_jwt_secret_key and paste secret

# Other secrets...  
devise_jwt_secret_key: "paste rails secret"

#Save with cmd + s, after press cmd + w for yml to close
#wait 2s, and verify in terminal for "File encrypted and saved".
Enter fullscreen mode Exit fullscreen mode

You can verify secret key in the terminal with:

rails credentials:show
Enter fullscreen mode Exit fullscreen mode
#config/initializers/devise.rb
config.jwt do |jwt|
  jwt.secret = Rails.application.credentials.fetch(:devise_jwt_secret_key)
  jwt.dispatch_requests = [
    [ "POST", %r{^/api/v1/login$} ]
  ]
  jwt.revocation_requests = [
    [ "DELETE", %r{^/api/v1/logout$} ]
  ]
  jwt.expiration_time = 15.minutes.to_i
end
Enter fullscreen mode Exit fullscreen mode

7. Set up a revocation strategy

  • Revocation of tokens is an important security concern. Devise-JWT gem comes with three revocation strategies out of the box. You can read more about them here Revocation JWT Strategies

  • I’ll be going with a recommended one which is to store a single valid user attached token with the user record in the users table.

  • Here, the model class acts itself as the revocation strategy. It needs a new string column with name jti to be added to the user. jti stands for JWT ID, and it is a standard claim meant to uniquely identify a token.

rails g migration addJtiToUsers jti:string:index:unique
Enter fullscreen mode Exit fullscreen mode

Update migration file

#db/migrate/xxxxxxxxx_add_jti_to_users.rb
def change
  add_column :users, :jti, :string, null: false
  add_index :users, :jti, unique: true
  # If you already have user records, you will need to initialize its `jti` column before setting it to not nullable. Your migration will look this way:
  # add_column :users, :jti, :string
  # User.all.each { |user| user.update_column(:jti, SecureRandom.uuid) }
  # change_column_null :users, :jti, false
  # add_index :users, :jti, unique: true
end
Enter fullscreen mode Exit fullscreen mode

Run migration

Rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Update User file to add revocation strategy

#app/models/user.rb
class User < ApplicationRecord
  include Devise::JWT::RevocationStrategies::JTIMatcher
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :jwt_authenticatable, jwt_revocation_strategy: self
end
Enter fullscreen mode Exit fullscreen mode

🔑 How JTI revocation works

  • When a user logs in, Device-JWT create an access token with a jti claim (“abc-123”) that matches the user’s current jti in the database

  • The token stays valid as long as a token.jti == user.jti

8. Add respond_with using jsonapi_serializers method

  • As we already added the jsonapi-serializer gem, we can now generate a serializer to configure the json format we’ll want to send to our back end API to the Front End.
rails generate serializer user id email created_at
Enter fullscreen mode Exit fullscreen mode
  • This will create a serializer with a predefined structure. Now, we have to add the attributes we want to include as a user response. So, we’ll add the user’s id, email and created_at. So the final version of user_serializer.rb looks like this:
#app/serializers/user_serializer.rb
class UserSerializer
  include JSONAPI::Serializer
  attributes :id, :email, :created_at
end
Enter fullscreen mode Exit fullscreen mode
  • Now, we have to tell devise to communicate through JSON by adding these methods in the RegistrationsController and SessionsController
class Api::V1::RegistrationsController < Devise::RegistrationsController
  respond_to :json

  private

  def respond_with(resource, _opts = {})
    if request.method == "POST" && resource.persisted?
      render json: {
        status: { code: 201, message: "Signed up successfully." },
        data: UserSerializer.new(resource).serializable_hash[:data][:attributes]
      }, status: :ok
    elsif request.method == "DELETE"
      render json: {
        status: { code: 204, message: "Account deleted successfully." }
      }, status: :ok
    else
      render json: {
        status: { code: 422, message: "User couldn't be created successfully. #{resource.errors.full_messages.to_sentence}" }
      }, status: :unprocessable_entity
    end
  end
end
Enter fullscreen mode Exit fullscreen mode
class Api::V1::SessionsController < Devise::SessionsController
  respond_to :json

  private

  def respond_with(resource, _opts = {})
    render json: {
      status: { code: 200, message: "Logged in successfully." },
      data: UserSerializer.new(resource).serializable_hash[:data][:attributes]
    }, status: :ok
  end

  def respond_to_on_destroy(*args)
    if current_user
      render json: {
        status: { code: 200, message: "Logged out successfully." }
      }, status: :ok
    else
      render json: {
        status: { code: 401, message: "Couldn't find an active session." }
      }, status: :unauthorized
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

9. Configure Devise for Rails 8 API mode

  • In Rails 7 there is an unfixed bug in Devise. An issue on the Devise-JWT repo that discusses this problem including a few fixes. A work around was to create a racks session fix "ActionDispatch::Request::Session::DisabledSessionError"

  • In Rails 8.1, store: false which will prevent Warden from storing user sessions.

#config/initializers/devise.rb
Devise.setup do |config|
  # ... existing code ...

  config.warden do |warden|
    warden.scope_defaults :user, { store: false }
  end
end
Enter fullscreen mode Exit fullscreen mode

10. Testing with Postman

  • User Signup

  • User Login

After login, check if authorization token was received

  • User Logout

Using JTI in server

rails c
irb(main):001> user = User.find_by(email: "user@gmail.com")
=> #<User id: 1, ... , jti: "311b4143-5ef6-49a1-8d16-20c2739db951">
irb(main):002> user.update!(jti: SecureRandom.uuid)
=> #<User id: 1, ... , jti: "c695964b-9b28-46b6-b97d-91cce9bd05d8">
Enter fullscreen mode Exit fullscreen mode
  • Now with devise-jwt, each user has a jti, a unique identifier to their access token. One way to revoke the old token is to update the jti in console, essentially removing access to the old token and creating a new token in the process.

  • How can JTI be used in future app?
    You can create a force_logout in user controllers to nuke the tokens as a button in FrontEnd.

def force_logout
  user = User.find(params[:id])
  User.transaction do
    user.update!(jti: SecureRandom.uuid)
  end
  head :no_content
end
Enter fullscreen mode Exit fullscreen mode
namespace :api, defaults: { format: :json } do
  namespace :v1 do
    resources :users do
      post :force_logout, on: :member
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Final JTI Explanation

  • In this setup, the jti value in the database only updates when a user explicitly triggers a revocation event, such as a logout request, password reset, or a force-logout perfomed via the console. Because the JTIMatcher strategy is being used, the jti stored in the user's database must match the jti claim inside the JWT for the token to be considered valid.

  • This means that even though the access tokens are short-lived (expiring every 15 minutes), Devise will continue to issue tokens using the same stored jti until it is changed. Once the jti in the database is updated, every existing token containing the old ID is atomically rejected, effectively logging the user out accross all devices.

  • You can test in jwt decoder sites like jwt.io

When preparing to go live

  • Remember to edit production environment for you app.
# config/environments/production.rb
 config.action_mailer.default_url_options = { host: 'yourapp.com', protocol: 'https' }
Enter fullscreen mode Exit fullscreen mode

Extra: Adding extra roles in JWT

  • When Building an app that distinguishes between a regular user and an admin. You’ll need roles. While you could fetch the user's role from the database on every request, adding it directly into the JWT is more efficient.

To do this, add a jwt_payload method to the User model

#app/models/user.rb
class User < ApplicationRecord
  # ... existing code ...

  def jwt_payload
    super.merge(
      "email" => email,
      "role"  => role # Assuming you have a role
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

Why include roles in the token?

  • Instant Authorization: Since Devise-JWT already validates the token on every request, the API immediately knows if a user is an admin or a guest.

  • A Smarter Frontend: React, Vue, or mobile frontend can decode the JWT to see the user's role instantly. This allows to show or hide admin dashboards and restrict navigation without having to make a separate "Who am I?" API call.

The "Role Sync" Situation

  • There is one important detail to keep in mind: JWTs are snapshots in time.

  • When promoting a user to "Admin" in the database, their current JWT won't know that yet, it still contains the "User" role from when they first logged in. By default, they would have to wait for their token to expire (15 minutes) to see the change.

  • The Better Solution: Since the JTIMatcher revocation strategy is being used, One can force an immediate update by rotating the user's jti at the same time their role is changed.

This is in development environment, rails c

# Upgrading a user's permissions
user.update!(role: 'admin', jti: SecureRandom.uuid)
Enter fullscreen mode Exit fullscreen mode
  • By changing the jti in the database, the old token (with the old role) is automatically rejected because the token jti no longer matches the user jti. This forces the user to re-authenticate, ensuring they get a fresh token with their new admin powers immediately.

Top comments (0)