devise jwt app for rails 7

Today we’ll walk through building a Rails backend using Devise-JWT which allows users to log in, access endpoints requiring authentication, and log out.

what is jwt

JWTs or JSON Web Tokens are most commonly used to identify an authenticated user. They are issued by an authentication server and are consumed by the client-server (to secure its APIs).

Setup

Create a rails app from the command line.

$ rails new devise-jwt-app -T -d postgresql --api
$ cd devise-jwt-app

Add devise, devise-jwt and rack-cors to the Gemfile.

gem 'devise'
gem 'devise-jwt'
gem 'rack-cors'

Run bundle to install them.

$ bundle install

configure the cors file to allow all Authorization headers.

config/initializers/cors.rb

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

Using origins ‘*’ is for our convenience. When deploying to production, set origins to the URL of your frontend app. Otherwise the whole internet will be able to hit your API. Though in some cases that’s desirable.

Setup Devise

$ rails generate devise:install

Now create a model and migration with devise, and migrate to generate tables.

$ rails g devise User
$ rails db:create db:migrate

Update the generated User model with the following code.

class User < ApplicationRecord

  devise :database_authenticatable,
         :jwt_authenticatable,
         :registerable,
         jwt_revocation_strategy: JwtDenylist
end

Create another model file called jwt_denylist.rb and paste in the following.

class JwtDenylist < ApplicationRecord
  include Devise::JWT::RevocationStrategies::Denylist

  self.table_name = 'jwt_denylist'
end

Create a migration to go along with it.

$ rails g migration CreateJwtDenylist

Update it to this.

class CreateJwtDenylist < ActiveRecord::Migration[7.0]
  def change
    create_table :jwt_denylist do |t|
      t.string :jti, null: false
      t.datetime :exp, null: false
    end
    add_index :jwt_denylist, :jti
  end
end

And migrate.

$ rails db:migrate

This sets up a table which tracks JWT tokens that have been logged out and should no longer have access to the app.

Controllers

run the following to create a controller for the user model.

$ rails g devise:controllers users -c sessions registrations

and add the fallowing to the sessions controller.

class Users::SessionsController < Devise::SessionsController
  respond_to :json

  private

  def respond_with(resource, _opts = {})
    render json: { message: 'You are logged in.' }, status: :ok
  end

  def respond_to_on_destroy
    log_out_success && return if current_user

    log_out_failure
  end

  def log_out_success
    render json: { message: "You are logged out." }, status: :ok
  end

  def log_out_failure
    render json: { message: "Hmm nothing happened."}, status: :unauthorized
  end
end

then the registrations controller.

class Users::RegistrationsController < Devise::RegistrationsController
  respond_to :json

  private

  def respond_with(resource, _opts = {})
    register_success && return if resource.persisted?

    register_failed
  end

  def register_success
    render json: { message: 'Signed up sucessfully.' }
  end

  def register_failed
    render json: { message: "Something went wrong." }
  end
end

Add one more controller, controllers/members_controller.rb, so we can test logged-in VS logged-out behaviour on an endpoint that required authenticating.

controllers/members_controller.rb

class MembersController < ApplicationController
  before_action :authenticate_user!

  def show
    render json: { message: "If you see this, you're in!", user: current_user }, status: :ok
  end
end

now theres a problem with devise and rails 7 considering the sessions and this is how we fix it.

first we need to create a new file in controller/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

then we need to add the following to the 'registrations' controller.

class Users::RegistrationsController < Devise::RegistrationsController

include RackSessionFix
  ...
end

now the session will work.


More Devise Setup

Update config/initializers/devise.rb. Add this to the file inside the config block.

config.jwt do |jwt|
  jwt.secret = Rails.application.credentials.devise[:jwt_secret_key]
end

This tells Devise-JWT to use a secret key specified in our credentials file to build tokens.

Now generate a secret key. And note the output. We’ll add this into our credentials file momentarily.

$ rake secret

copy the secret key.
This is a secret key that should be kept secret.

Open the credentials file from the command line.

$ EDITOR=nano rails credentials:edit

And paste in the following, with the key generated from running rake secret above.

devise:
  jwt_secret_key: <rake secret key>

secret key

Then save the file.

ctrl+x, then y, then enter to close and save if you’re using the nano editor.

Routes


Update your routes so they point to your new controllers, rather than to the default devise controllers.

config/routes.rb
Rails.application.routes.draw do
  devise_for :users,
             controllers: {
                 sessions: 'users/sessions',
                 registrations: 'users/registrations'
             }
  get '/member-data', to: 'members#show'
end

Now let’s make some request to test it out.

Testing

we are going to test the routes we just created with insomnia but you can use postman or whatever you prefer.

first we start the server.

rails s

then we can make requests to the following endpoints to create a user.

http://localhost:3000/users

Register a user.

{
	"user": {
		"email": "test@test.com",
		"password": "password"
	}
}

signUp

we can find the authorization token in the response in the header.

authHeader

now to log in.

http://localhost:3000/users/sign_in
{
	"user": {
		"email": "yahya1@gmail.com",
		"password": "123123"
	}
}

signIn
and to get the member data.

http://localhost:3000/member-data

we need to copy the authorization token from the response we got from the sign up request or the sign in request. and add it to the header, that we are going to send to the server.

authHeader
then we send the GET request to the server.

now we get the response with the user data.

memberData

i hope you enjoyed this tutorial and learned a lot about Devise and Rails.

if you have any questions, please feel free to contact me.

and don't forget to leave a star on the repo ^^.