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'
2. Configure Rack-CORS for API only application
Update Gemfile to add/uncomment gem 'rack-cors'
gem 'rack-cors'
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
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.
4. Configure devise
rails generate devise:install
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 = []
5. Create User model
rails generate devise User
rails db:create db:migrate
Generate Controllers to later handle sign-up, login and logout
rails g devise:controllers api/v1 -c sessions registrations
Update Sessions and Registrations Controllers
#app/controllers/users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
respond_to :json
end
#app/controllers/users/registrations_controller.rb
class Users::RegistrationsController < Devise::RegistrationsController
respond_to :json
end
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
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
Run in terminal to open .yml
EDITOR='code --wait' rails credentials:edit
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".
You can verify secret key in the terminal with:
rails credentials:show
#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
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
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
Run migration
Rails db:migrate
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
🔑 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
- 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
- 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
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
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
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">
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
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :users do
post :force_logout, on: :member
end
end
end
Final JTI Explanation
In this setup, the
jtivalue 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' }
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
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)
- 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)