Welcome back! I appreciate you sticking with me on this project. Today, we’re going to jump straight into user authentication and explore why I went with Devise and JWT for this feature.
In previous projects, I’ve used Devise for traditional Rails apps and found it to be simple and reliable for handling user registration, login, and password management. But when it comes to API-only apps—where you need stateless authentication—JWT (JSON Web Token) is a much better solution than session-based approaches.
By combining Devise with JWT, I get the best of both worlds: Devise makes user management (registration, login, etc.) a breeze, while JWT provides secure, stateless authentication that fits perfectly with an API setup.
Let’s dive into how it all works!
1. Add required gems
First, we need to include the necessary gems for Devise and JWT. Add these to your Gemfile
:
gem 'devise'
gem 'devise-jwt'
Run bundle install
to install the gems.
2. Install Devise
Next, install Devise and generate the User
model:
rails generate devise:install
rails generate devise User
In your User
model (app/models/user.rb
), include the following Devise modules:
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
end
Run migrations to update your database schema:
rails db:migrate
3. Enabling CORS
Since the API will interact with a frontend (likely running on a different domain), we must enable CORS to allow cross-origin requests. This step is critical for securely allowing your frontend (e.g., a React app) to communicate with your backend API.
First, add the rack-cors gem:
gem 'rack-cors'
Run bundle install
and uncomment the contents of the file config/initializers/cors.rb
and add the following code:
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
expose: ["Authorization"]
end
end
Also, ensure that Devise isn’t using navigational formats by updating config/initializers/devise.rb
. uncomment and edit the following line:
config.navigational_formats = []
This will prevent devise from using flash messages which are a default feature and are not present in Rails api mode.
And lastly, add this toconfig/environments/development.rb
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
This helps with Devise's password recovery and other mailer features.
4. Set up devise-jwt
Ensure that your config/master.key
file exists so that Rails can decrypt credentials containing the secret key base. If it’s missing, you can generate it by running:
EDITOR="code --wait" bin/rails credentials:edit
Rails will generate the config/master.key
when you save and close the file.
5. JWT Configuration
In config/initializers/devise.rb
, configure JWT for the Rails app:
config.jwt do |jwt|
jwt.secret = Rails.application.credentials.fetch(:secret_key_base)
jwt.dispatch_requests = [['POST', %r{^/users/sign_in$}]]
jwt.revocation_requests = [['DELETE', %r{^/users/sign_out$}]]
jwt.expiration_time = 120.minutes.to_i
end
This config ensures that JWT is used for login (sign-in) and logout (sign-out), with tokens expiring after two hours.
6. Creating custom controllers
Next, we generate the devise controllers (sessions and registrations) to handle sign-ins and sign-ups
rails g devise:controllers users -c sessions registrations
Then we must override the default routes provided by devise and add route aliases.
Rails.application.routes.draw do
devise_for :users, controllers: {
sessions: 'users/sessions',
registerations: 'users/registerations'
}
end
7. Revocation Strategy
To manage token revocation and prevent the reuse of tokens after a user logs out, I used the JTIMatcher strategy. This strategy works by adding a unique identifier to each token (called a JTI) and storing it in the database. When a user logs out, the JTI is invalidated, making any token associated with it unusable.
In your User
model, the jti
field is used to track valid tokens:
rails g migration addJtiToUsers jti:string:index:unique
Then, update the migration file:
class AddJtiToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :jti, :string, null: false
add_index :users, :jti, unique: true
end
end
Run the migration:
rails db:migrate
Finally, update your User
model:
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
def jwt_payload
super
end
end
8. Custom Devise Controllers
To handle JSON responses, we create custom controllers for user registration and session management:
We’ll leverage some Devise helper methods to define how the application behaves in specific scenarios:
respond_with: manages the response for POST
requests, such as after registration or login.
respond_to_on_destroy: handles the response for DELETE
requests, like when logging out.
in app/controllers/users/registrations_controller.rb
:
class Users::RegistrationsController < Devise::RegistrationsController
respond_to :json
private
def respond_with(current_user, _opts = {})
if resource.persisted?
render json: {
status: {code: 200, message: 'Signed up successfully.'},
data: UserSerializer.new(current_user).serializable_hash[:data][:attributes]
}
else
render json: {
status: {message: "User couldn't be created successfully. #{current_user.errors.full_messages.to_sentence}"}
}, status: :unprocessable_entity
end
end
end
in app/controllers/users/sessions_controller.rb
:
class Users::SessionsController < Devise::SessionsController
respond_to :json
private
def respond_with(resource, _options = {})
render json: { status: { code: 200, message: 'User signed in successfully' }, data: current_user }, status: :ok
end
def respond_to_on_destroy
token = request.headers['Authorization']&.split(' ')&.last
if token.nil?
render json: { status: 401, message: 'Token missing or malformed' }, status: :unauthorized
else
begin
jwt_payload = JWT.decode(token, Rails.application.credentials.fetch(:secret_key_base), true, algorithm: 'HS256').first
current_user = User.find(jwt_payload['sub'])
if current_user
render json: { status: 200, message: 'Signed out successfully' }, status: :ok
else
render json: { status: 401, message: 'User has no active session' }, status: :unauthorized
end
rescue JWT::DecodeError
render json: { status: 401, message: 'Invalid token' }, status: :unauthorized
end
end
end
end
This configuration allows us to handle sign-in and sign-out responses in JSON format and validates JWT tokens on sign-out.
9. Updating Routes
We now define routes for Devise, pointing them to the custom controllers:
Rails.application.routes.draw do
devise_for :users, controllers: {
sessions: 'users/sessions',
registrations: 'users/registrations'
}
end
This connects the user authentication flow to the appropriate controllers.
Phew! That was quite a bit of coding. Now, the final step is to test whether all our hard work has paid off and if everything is functioning as expected
10. Testing with Postman
In application terminal, run rails s
to start the server.
To test the API, use Postman to send HTTP requests to the app.
Creating a user:
Logging in:
After we log in, we can check that the authorization token was received:
Logging out:
When we log out the authorization token needs to be passed, for testing, we have to manually add it to Postman:
What’s Next?
That’s all for now! In the next post, I’ll dive into creating models and controllers, and implementing one-to-many relationships with validations. Stay tuned!
Top comments (0)