DEV Community

loading...
Cover image for Devise and JWT in Rails

Devise and JWT in Rails

dhintz89 profile image Daniel Hintz ・9 min read

I needed to implement Devise and JWT using Rails (Rails 5), and I thought, how hard could this be? Boy was I naive... Now there is a lot of information out there on how to do this, but each resource was using a different method and nothing really seemed to work. Well, I've finally figured it out and I want to share it with the world for 2 reasons:

  1. It may save someone days of researching and trial-and-error.
  2. Selfishly, I want to know where I can go to look it up for next time

Warning, this post will assume some knowledge of Rails and a few popular gems, it's a little bit more advanced than my normal stuff so far. So here we go.

How does it work?

First thing's first, there are a few ways this can be handled. There is (was?) a Devise-JWT gem that integrated JWT and worked very similarly to Devise's regular flow. When I tried to go that route, it did not work and I wasted many, many hours troubleshooting. I did eventually succeed in registration, but the sign_in functionality was still not working. It's very probable that this was do to user error, but regardless, I found my way to be much simpler.

Here's how it works.
Devise and JWT Diagram

So basically, you can really think about this in two steps. Step 1 is the standard devise-driven authentication. Step 2 is passing the JSON Web Token back and forth.

Implementation

Project Generation

First, let's build our project. Since we don't need the full Rails functionality because we'll be setting up a separate front-end, we can use the --api flag rails new example-project --api. One of the effects of this flag is that the project will be set up without rails sessions - this is important.

Gemfile

Once we've built our project, first thing we'll do is build out the Gemfile. For the purposes of our authentication flow, we'll need 3 gems

  • devise for actual authentication
  • jwt for handling the JSON Web Tokens we'll be passing back and forth.
  • bcrypt for password-related unit testing - this only needs to be included in the test environment because otherwise it's included in Devise.
  • BONUS: I pretty much always add pry to help with debugging, and it comes in real handy when I need to check what params are coming over.

Devise Initializer

To configure Devise, we'll run rails generate devise:install from our console to create an initializer file: config/initializers/devise.rb. The good news is that we can largely keep the default configuration; the only special thing we need to do is to set config.skip_session_storage = [:http_auth] (about quarter way down the file).

User Model

Now we need to set up our user model. Devise has a special way to do this by running rails generate devise User. This command creates a User model and prefills it with some Devise functionality, it also creates a database 'devise_create_users' migration, and adds a line to the routes file: devise_for :users which creates routes to the default Devise Controllers.

Once the User model is created, we can finish configuring Devise by selecting which modules we want and adding it after the devise macro. For my app, I just used the basic defaults: devise :database_authenticatable, :registerable

One last thing before we can call the User model ready. Since a given JSON Web Token (JWT) will be associated to a given user, it makes sense to think of a user "creating" their token. Additionally, the goal is to get as much of the app's logic in the models, so to address both of these concerns we will place the logic of creating a JWT in the User model. Here we use the JWT gem to encode a token containing only the user's id. How can the id be the only thing we need you ask? Thinking back to our "How Does It Work" Diagram above, remember that the user will need to pass in their credentials as parameters at the sign-in page and, if successful, the server will issue an encrypted token for them. This is that token, so it will only be used to authenticate that the user is who they say they are once they've already logged in and they try to make a subsequent call to the API. Thus, we only need a way to identify the user: their unique id attribute works perfectly for this purpose.

def generate_jwt
  JWT.encode({id: id, exp: 60.days.from_now.to_i}, Rails.application.secrets.secret_key_base)
end
Enter fullscreen mode Exit fullscreen mode

Routes

As stated above, the rails generate devise User generator will create a route for us automatically that looks like this: devise_for :users. For our purposes, the default controllers aren't going to work on their own because they are meant to operate via sessions, which we will not have in our api-only implementation. So, we'll need to overwrite some of the default functionality - to do this, we need to point to custom registrations and sessions controllers:

devise_for :users,
controllers: {
  registrations: :registrations,
  sessions: :sessions
}
Enter fullscreen mode Exit fullscreen mode

Database

Also stated above, the rails generate devise User generator will create our database migration for us, so the only change we need to make is uncommenting any non-default modules you added in your User model, as well as adding any custom fields you may need. Once you're done, run rake db:migrate and we're done here.

Intermission (Coffee Break)

We've gotten through a lot already, but there's quite a bit more to come, so before we get into the controllers, which contain most of our logic and functionality, take a quick breather and grab a fresh cup of coffee. If you're following along, this is a good time to double check that everything is correct in your app so far...

Ready to continue? Okay, let's do this!

Controllers

There are three controllers that we're going to be concerned with for this, and each of these 3 controllers will have a specific job from the diagram at the top of this article.

  1. The Application Controller is where we will process a JWT when a user sends a request to our API. It's vital to keep in mind that the Application Controller is not concerned with credentials - it simply checks for a valid JWT.
  2. The Registrations Controller is where a user will create his/her credentials, and it will assign the JWT to the user once complete.
  3. The Sessions Controller is where a user will authenticate his/her credentials and it will assign the JWT to the user if successful.

Application Controller < ActionController::API

We will set up our JWT processing functionality first because, once a JWT is assigned, we'll want to check to make sure it's working correctly. Since we know that we will be passing in JSON, we will start off the Application Controller with the following line respond_to :json. Since all other controllers inherit from the Application Controller, we only need to do this for this controller - it will automatically be passed down to the rest. This is also where we'll want to provide our app with similar private methods to what the standard Devise implementation would give us, so let's set up our authentication method authenticate_user! as well as a signed_in? and current_user method, then we'll look at how to get them to work.

For our authenticate_user!, we know that we want this to reject a user as unauthorized unless they are correctly signed in. We also know we'll eventually have a signed_in? method available, so let's go ahead and proceed using that:

def authenticate_user!(options = {})
  head :unauthorized unless signed_in?
end
Enter fullscreen mode Exit fullscreen mode

But for this to work, of course, we need to define signed_in?. Default Devise does this by checking the session for the presence of a user_id. We won't have a session for this, but what we will have is a JWT. We now know that we need a method to somehow pull a user's id out of the JWT and return it. Let's call it @current_user_id and use that future value in our signed_in? method like so:

def signed_in?
  @current_user_id.present?
end
Enter fullscreen mode Exit fullscreen mode

While we're at it, since we know that we'll have a @current_user_id to work with, let's use it to define our current_user method too. We need this to take the id and search our database for a corresponding user record:

def current_user
  @current_user ||= super || User.find(@current_user_id)
end
Enter fullscreen mode Exit fullscreen mode

That's easy enough, essentially just copying the Devise methods, now we just have to find a way to extract that id from a passed JWT. One final reminder: remember that this controller is NOT meant to make sure that the user authenticates against his/her credentials, it's just to see whether they are signed in or not by looking at the JWT. If a user HAS a valid JWT, it means that they have correctly authenticated their credentials and the server gave them one. With that in mind, this is actually super simple using the jwt gem:

def process_token
  jwt_payload = JWT.decode(request.headers['Authorization'].split(' ')[1], Rails.application.secrets.secret_key_base).first
  @current_user_id = jwt_payload['id']
end
Enter fullscreen mode Exit fullscreen mode

That will work, assuming that there IS an Auth header, and that it has a valid JWT. I'm not willing to bet that either of these are always going to happen, so let's put some error handling around it. We want to throw an error if an invalid JWT is sent, but not if there is no Auth header sent at all:

def process_token
  if request.headers['Authorization'].present?
    begin
      jwt_payload = JWT.decode(request.headers['Authorization'].split(' ')[1].remove('"'), Rails.application.secrets.secret_key_base).first
      @current_user_id = jwt_payload['id']
    rescue JWT::ExpiredSignature, JWT::VerificationError, JWT::DecodeError
      head :unauthorized
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

There! Now there's just one last step. We need to make sure that the token is processed before we try to take any other action. To do this, we just need to add before_action :process_token underneath respond_to :json. Now whenever our app is called, it will process the token (if provided) and then take whatever action is required.

Registrations Controller < Devise::RegistrationsController

Okay, next step is to provide our app the ability to register a new user and assign them a JWT to be passed to our Application Controller for processing. As long as we're just using the default attributes for Devise (and calling them "sign_up_params", we don't need to worry about whitelisting parameters because Devise is already doing it for us. The reason we need to have our own controller is so that we can have the user instance build its token for the controller to deliver it. On the client side, we would use this returned token to store in a httpOnly cookie, (or whatever other storage option you prefer).

def create
  user = User.new(sign_up_params)

  if user.save
  token = user.generate_jwt
    render json: token.to_json
  else
    render json: { errors: { 'email or password' => ['is invalid'] } }, status: :unprocessable_entity
  end
end
Enter fullscreen mode Exit fullscreen mode

Sessions Controller < Devise::SessionsController

Finally, the last step in our implementation! Just gotta set up the Sessions Controller so that a user can return and sign back in, and it works the same way as the Registrations Controller. The user will submit params through the front-end, including their email, which our API will use to query the database and return our user instance. Then we'll validate that the password they provided matches the stored password and, if successful, we will distribute a JWT:

def create
  user = User.find_by_email(sign_in_params[:email])

  if user && user.valid_password?(sign_in_params[:password])
    token = user.generate_jwt
    render json: token.to_json
  else
    render json: { errors: { 'email or password' => ['is invalid'] } }, status: :unprocessable_entity
  end
end
Enter fullscreen mode Exit fullscreen mode

Wrap-Up

So there it is. This is how I was finally able to get JWT working with server-side authentication using Devise, the de-facto standard for Rails. Once I realized that JWT is really a separate process from authenticating credentials, it wasn't so bad to figure out. Let me know what you think in the comments. Is there a better way to combine these two gems? Are there major issues with this implementation? If you've successfully used devise-jwt, what is the secret??

Thanks so much for reading and hanging in there to the end! Below this is just the final code (minus Gemfile and Initializer), in case you want to see it all in one place.

Full Code:

# User.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :recoverable, :rememberable, :validatable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable

  def generate_jwt
    JWT.encode({id: id, exp: 60.days.from_now.to_i}, Rails.application.secrets.secret_key_base)
  end

end


# Routes.rb
Rails.application.routes.draw do
  devise_for :users,
  controllers: {
    registrations: :registrations,
    sessions: :sessions
  }

  root to: "home#index"
end


# Database Schema
  create_table "users", force: :cascade do |t|
    t.string "email", default: "", null: false
    t.string "encrypted_password", default: "", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["email"], name: "index_users_on_email", unique: true
  end


# ApplicationController.rb
class ApplicationController < ActionController::API
  respond_to :json
  before_action :process_token

  private

  # Check for auth headers - if present, decode or send unauthorized response (called always to allow current_user)
  def process_token
    if request.headers['Authorization'].present?
      begin
        jwt_payload = JWT.decode(request.headers['Authorization'].split(' ')[1], Rails.application.secrets.secret_key_base).first
        @current_user_id = jwt_payload['id']
      rescue JWT::ExpiredSignature, JWT::VerificationError, JWT::DecodeError
        head :unauthorized
      end
    end
  end

  # If user has not signed in, return unauthorized response (called only when auth is needed)
  def authenticate_user!(options = {})
    head :unauthorized unless signed_in?
  end

  # set Devise's current_user using decoded JWT instead of session
  def current_user
    @current_user ||= super || User.find(@current_user_id)
  end

  # check that authenticate_user has successfully returned @current_user_id (user is authenticated)
  def signed_in?
    @current_user_id.present?
  end

end


# RegistrationsController.rb
class RegistrationsController < Devise::RegistrationsController

  def create
    user = User.new(sign_up_params)

    if user.save
      token = current_user.generate_jwt
      render json: token.to_json
    else
      render json: { errors: { 'email or password' => ['is invalid'] } }, status: :unprocessable_entity
    end
  end

end


# SessionsController.rb
class SessionsController < Devise::SessionsController

  def create
    user = User.find_by_email(sign_in_params[:email])

    if user && user.valid_password?(sign_in_params[:password])
      token = current_user.generate_jwt
      render json: token.to_json
    else
      render json: { errors: { 'email or password' => ['is invalid'] } }, status: :unprocessable_entity
    end
  end

end
Enter fullscreen mode Exit fullscreen mode

Discussion (5)

pic
Editor guide
Collapse
thorstenhirsch profile image
Thorsten Hirsch

Your article will help me a lot, because I have to add JWT handling to a Rails application that already uses Devise. Thank you! Just one thing:

On the client side, we would use this returned token to store in localStorage...

It is strongly discouraged to save the token in localStorage due to XSS attacks. Read more about it here or search for articles on that topic on dev.to (there are a few). A better solution is to use an httpOnly cookie.

Collapse
dhintz89 profile image
Daniel Hintz Author

Glad it's helpful! It's worked for me twice so far, but if you run into any problems and have to solve around them, please add another comment about it.

Also, thanks for the suggestion - I've gone ahead and made the change in the article. I haven't had the chance to dig as much into client-side storage strategies as I'd like, so I'm really glad you called that out.

Collapse
dhintz89 profile image
Daniel Hintz Author

One other callout:
In real-world apps, you may need to look into more securely logging out a user.

It's on my radar to research as soon as I get the chance, and I'll post about it once I do. But as an example for the mean-time, I've briefly read about adding a database table for blacklisted tokens so that the user can't make calls with an old token without logging back in, or conversely, adding a whitelisted token column to your users table. A simpler option may be to just set the JWT to expire after a much shorter time (like 1 day or less).

Collapse
dakotalmartinez profile image
Dakota Lee Martinez

hey Daniel! I was just going through this last week and went through a tutorial that really helped out. I made a git repo with a detailed README describing what I did differently from the tutorial and then beyond it how you could store tokens client side: github.com/dakotalmartinez/rails-d.... As far as localStorage goes for storing tokens, from what I've seen there's actually quite a bit of debate there. Some people say it's totally bad and should be avoided, others say that storing the token in a cookie only makes it slightly more difficult for an attacker to exploit XSS vulnerabilities. If an attacker can run JS on your domain, they can use the cookie to send requests to your API whether or not they can access it via the JS it can be included with a fetch request. Moral of the story, XSS is bad, so don't take user input and put it straight into innerHTML = without encoding/escaping it. portswigger.net/web-security/cross...

Collapse
dhintz89 profile image
Daniel Hintz Author

Hi Dakota, thank you for posting this link!!

Your tutorial looks great. I haven't had a chance to follow along with my own code yet, but it seems to be exactly what I needed about 8 months ago when I was trying to implement Devise-JWT 😆

A lot of the content looks very familiar, so it will be interesting to dig in and see where I went wrong. Could even be due to Rails version (I'm still on 5)...maybe it's time for me to finally update.