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:
- It may save someone days of researching and trial-and-error.
- 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.
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
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
}
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.
- 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.
- The Registrations Controller is where a user will create his/her credentials, and it will assign the JWT to the user once complete.
- 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
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
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
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
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
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
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
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 = 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 = user.generate_jwt
render json: token.to_json
else
render json: { errors: { 'email or password' => ['is invalid'] } }, status: :unprocessable_entity
end
end
end
Top comments (12)
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:
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.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.
Hey!
I was implementing auth in my new project following your instructions, and I implemented the registration controller as follows :
gist.github.com/prp-e/14e886c2eb51...
as you can see, I used devise's controllers and modified them.
They seem to be working fine, and this is my request:
So, when I send the request, if it's invalid, it returns the suitable error for being invalid. But when I send valid data, it doesn't return any freaking thing.
I'd be thankful if you help me with this.
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).
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...
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.
Hey Daniel, this is great--thanks for posting this resource!
One quick q about your full code at the bottom: in SessionsController.rb and RegistrationsController.rb, "generate_jwt" should be called from the "user" object, not "current_user", correct?
I've been using the one you had earlier in your article and that works for me:
token = user.generate_jwt
Let me know if I misread something--I'm still new to Ruby / Rails. Thanks again!
Hi tiQu. You are correct, great catch! I've fixed it in the article now. Thanks for calling it out.
Hi Daniel, I can not access to current_user in create controllers method.
When I post from the frontend, tells me ActiveRecord::RecordNotFound (Couldn't find User without an ID).
Any ideas to solve it?
I have the same problem. How did you solve it? Thanks
Does this only work for api mode? My rails app serves as a web app and an api. Users need to be able to login on the website and also use it by api.
Thanks for sharing this.