Rails Devise JWT Tutorial (updated)
The original post (from December 2020) can be found on GitHub, but it’s been a couple of years so I wanted to bring it up to date with newer versions of ruby/rails/devise/devise-jwt and to swap out jsonapi-serializer for fast_jsonapi.
I created a GitHub template repository for this approach if you want to implement it in a project and play around with it. I also recorded a walkthrough of the content in this post and shared it on YouTube as well if you’d like to follow along with that yourself.
The jsonapi-serializer gem is a lightning fast JSON:API serializer for Ruby Objects. It performs better than ActiveModel Serializers and is a fork of the fast_jsonapi gem that was built by Netflix but is no longer actively maintained.
Thanks to this tutorial on Tech Compose and the devise and devise-jwt gems. Also this blog post on token recovation strategies was helpful to me in putting this together.
Devise and JWT
Devise-jwt is a devise extension which uses JSON Web Tokens(JWT) for user authentication. With JSON Web Tokens (JWT), rather than using a cookie, a token is added to the request headers themselves (rather than stored/retrieved as a cookie). This isn’t performed automatically by the browser (as with cookies), but typically will be handled by a front-end framework as part of an AJAX call.
Create a new Rails API app
In this step, We need to create a rails application with api_only mode with optional database params to prepare for deployment later on.
Important Note: make sure to type this command in your terminal rather than copying and pasting it, I have had issues on my machine with the flags not registering if the command was copied and pasted!
$ rails new rails-jwt-tutorial -–api -–database=postgresql -T
Here, I have created a rails 7 application using postgresql (Default SQLite). Another note, If you are my GitHub template, you’ll need to make sure that you rename the databases in the config/database.yml
that would be generated by this step.
Configure Rack Middleware
As this is an API Only application, we have to handle ajax requests. So for that, we have to Rack Middleware for handling Cross-Origin Resource Sharing (CORS)
To do that, Just uncomment the
gem 'rack-cors'
line from your generated Gemfile. And add the following contents to the config/initialzers/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: ['access-token', 'expiry', 'token-type', 'Authorization'],
methods: [:get, :patch, :put, :delete, :post, :options, :show]
)
end
end
Here, we can see that there should be an “Authorization” header exposed which will be used to dispatch and receive JWT tokens in Auth headers. I have gotten it working without the other exposed headers before, but I have heard from other instructors that some students have had issues until they exposed the other headers as well, so I’ve included them above as well.
Note that when you’re ready to deploy, you will need to add an origin that matches the domain for your deployed client.
Add the needed Gems
Here, we are going to add gem like ‘devise’ and ‘devise-jwt’ for authentication and the dispatch and revocation of JWT tokens and the‘jsonapi-serializer’ gem for formatting json responses.
gem 'devise'
gem 'devise-jwt'
gem 'jsonapi-serializer'
Then, do
bundle install
Configure devise
By running the following command to run a generator
$ rails generate devise:install
Also, add the following line to config/environments/development.rb
config.action_mailer.default_url_options = { host: 'localhost', port: 4000 }
It is important to set our navigational formats to empty in the generated devise.rb by uncommenting and modifying the following line since it’s an api only app.
config.navigational_formats = []
Create User model
You can create a devise model to represent a user. It can be named as anything. So, I’m gonna be going ahead with User. Run the following command to create User model.
$ rails generate devise User
Then run migrations using,
$ rails db:create db:migrate
Create devise controllers and routes
We need to create two controllers (sessions, registrations) to handle sign ups and sign ins.
rails g devise:controllers users -c sessions registrations
specify that they will be responding to JSON requests. The files will look like this:
class Users::SessionsController < Devise::SessionsController
respond_to :json
end
class Users::RegistrationsController < Devise::RegistrationsController
respond_to :json
end
Then, add the routes aliases to override default routes provided by devise in the routes.rb
Rails.application.routes.draw do
devise_for :users, path: '', path_names: {
sign_in: 'login',
sign_out: 'logout',
registration: 'signup'
},
controllers: {
sessions: 'users/sessions',
registrations: 'users/registrations'
}
end
Configure devise-jwt
Add the following lines to config/initializers/devise.rb
config.jwt do |jwt|
jwt.secret = Rails.application.credentials.fetch(:secret_key_base)
jwt.dispatch_requests = [
['POST', %r{^/login$}]
]
jwt.revocation_requests = [
['DELETE', %r{^/logout$}]
]
jwt.expiration_time = 30.minutes.to_i
end
Here, we are just specifying that on every post request to login call, append JWT token to Authorization header as “Bearer” + token when there’s a successful response sent back and on a delete call to logout endpoint, the token should be revoked.
The jwt.expiration_time
sets the expiration time for the generated token. In this example, it’s 30 minutes.
Set up a revocation strategy
Revocation of tokens is an important security concern. The devise-jwt
gem comes with three revocation strategies out of the box. You can read more about them in this blog post on token recovation strategies.
For now, we’ll be going with the one they recommended 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.
It works like the following:
- When a token is dispatched for a user, the
jti
claim is taken from thejti
column in the model (which has been initialized when the record has been created). - At every authenticated action, the incoming token
jti
claim is matched against thejti
column for that user. The authentication only succeeds if they are the same. - When the user requests to sign out its
jti
column changes, so that provided token won’t be valid anymore.
In order to use it, you need to add the jti
column to the user model. So, you have to set something like the following in a migration:
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
To add this, we can run
rails g migration addJtiToUsers jti:string:index:unique
And then make sure to add null: false
to the add_column
line and unique: true
to the add_index
line
Important: You are encouraged to set a unique index in the jti
column. This way we can be sure at the database level that there aren’t two valid tokens with same jti
at the same time.
Then, you have to add the strategy to the model class and configure it accordingly:
class User < ApplicationRecord
include Devise::JWT::RevocationStrategies::JTIMatcher
devise :database_authenticatable, :registerable, :validatable,
:jwt_authenticatable, jwt_revocation_strategy: self
end
Next, we’ll run migrations using
rails db:migrate
Add respond_with using jsonapi_serializers method
As we already added the jsonapi-serializer
gem, we can generate a serializer to configure the json format we’ll want to send to our front end API.
$ rails generate serializer user id email created_at
It 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:
class UserSerializer
include JSONAPI::Serializer
attributes :id, :email, :created_at
end
We can access serializer data for single record by,
UserSerializer.new(resource).serializable_hash[:data][:attributes].to_json
And multiple records by,
UserSerializer.new(resource).serializable_hash[:data].map{|data| data[:attributes].to_json}
Now, we have to tell devise to communicate through JSON by adding these methods in the RegistrationsController
and SessionsController
class Users::RegistrationsController < Devise::RegistrationsController
respond_to :json
private
def respond_with(resource, _opts = {})
if request.method == "POST" && resource.persisted?
render json: {
status: {code: 200, message: "Signed up sucessfully."},
data: UserSerializer.new(resource).serializable_hash[:data][:attributes]
}, status: :ok
elsif request.method == "DELETE"
render json: {
status: { code: 200, 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 Users::SessionsController < Devise::SessionsController
respond_to :json
private
def respond_with(resource, _opts = {})
render json: {
status: {code: 200, message: 'Logged in sucessfully.'},
data: UserSerializer.new(resource).serializable_hash[:data][:attributes]
}, status: :ok
end
def respond_to_on_destroy
if current_user
render json: {
status: 200,
message: "logged out successfully"
}, status: :ok
else
render json: {
status: 401,
message: "Couldn't find an active session."
}, status: :unauthorized
end
end
end
Remember, you can use the attribute method in a serializer to add a property to the JSON response based on an expression you return from a block that has access to the object you’re serializing. For example, you can modify the column name and data format by overwrite attribute:
attribute :created_date do |user|
user.created_at && user.created_at.strftime('%m/%d/%Y')
end
Here, we’re adding a created_date attribute that will reformat the user’s created_at value in the one we specify.
Here you can get detailed information on jsonapi-serializer.
Sanity Check: Try it out in Postman!
To start up our local dev server on port 4000, we’ll want to change some default configuration so we don’t need to add a flag every time.
# in config/puma.rb
port ENV.fetch("PORT") { 3000 }
replace with:
port ENV.fetch("PORT") { 4000 }
Now we can run
rails s
And try out a POST request to ‘/signup’ with the following body:
{
"user": {
"email": "test@test.com",
"password": "password"
}
}
When we try this we should see a response that looks like this:
{
"status": {
"code": 200,
"message": "Signed up sucessfully."
},
"data": {
"id": 1,
"email": "test@test.com",
"created_at": "2023-01-27T03:51:52.255Z",
"created_date": "01/27/2023"
}
}
But the first time I did it, I actually got an error that looked something like this:
Completed 500 Internal Server Error in 301ms (ActiveRecord: 7.6ms | Allocations: 13211)
ActionDispatch::Request::Session::DisabledSessionError
(Your application has sessions disabled. To write to the
session you must first configure a session store):
This is a bit frustrating, as in API only mode we’re not going to be using session cookies. This is an unfixed bug in Devise with Rails 7 at the moment. There’s an issue on the Devise-JWT repo that discusses this problem including a few fixes. My pick was to go with a fix that is focused on giving devise a fake rack session hash that has enabled?
set to false to avoid the error that it would otherwise raise.
To implement the fix, create a new file in controllers/concerns:
# app/controllers/concerns/rack_session_fix.rb
module RackSessionFix
extend ActiveSupport::Concern
class FakeRackSession < Hash
def enabled?
false
end
end
included do
before_action :set_fake_rack_session_for_devise
private
def set_fake_rack_session_for_devise
request.env['rack.session'] ||= FakeRackSession.new
end
end
end
And then, at the top of both our controllers:
class Users::SessionsController < Devise::SessionsController
include RackSessionFix
...
end
class Users::RegistrationsController < Devise::RegistrationsController
include RackSessionFix
...
end
Now, we’ll be able to signup and login
Adding a ‘/current_user’ endpoint
We’ll probably want to create an endpoint that will return the current user given a valid JWT in the headers. This will be useful in our frontend code to be able to recognize if we have an active session before visiting a client side route that shouldn’t be accessible without an active session.
rails g controller current_user index
And then in config/routes.rb
find this line:
get 'current_user/index'
and replace it with
get '/current_user', to: 'current_user#index'
Now, fill in the CurrentUserController
so it looks like this:
class CurrentUserController < ApplicationController
before_action :authenticate_user!
def index
render json: UserSerialzier.new(current_user).serializeable_hash[:data][:attributes], status: :ok
end
end
Adding the before_action :authenticate_user
will ensure that we only see a 200 response if we have a valid JWT in the headers. If we don’t this endpoint should return a 401
status code.
Reminder for React Users
If you’re going to use this api with a React application, you may want to set the default port to some other port (I’m using 4000) so that it won’t conflict with the react dev server. If you set the default to port for the API to 4000, you’ll have consistency with the two ports when you run both servers simultaneously. If you don’t do this, you’d always need to make sure you start the front end dev server before the backend one to make sure that the ports are consistent.
To do this, you’ll need to make changes in two different files: config/environments/development.rb
and config/puma.rb
. In config/environments/development.rb
, replace the following:
# config/environments/development.rb
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
with
config.action_mailer.default_url_options = { host: 'localhost', port: 4000 }
And, in config/puma.rb
, replace the following:
port ENV.fetch("PORT") { 3000 }
with
port ENV.fetch("PORT") { 4000 }
After you’ve made these changes, you can run
rails s
And it will boot up the server on port 4000.
You can see here how your API will respond to requests made to protected routes in differing states of authorization.
Header | Status | Content |
---|---|---|
Authorization Header with valid JWT | 200 | successful response containing JSON |
Authorization Header with expired JWT | 401 | text response indicating Signature has expired |
Authorization Header with no JWT | 401 | text response indicating You need to sign in or sign up before continuing. |