Using Devise for Authentication in Rails Without Database Stored Accounts
We can pretty much say that thanks to the venerable Devise gem, the authentication problem has been solved in Ruby on Rails. There are some instances however, when the requirements veer a little further away from convention and some customization needs to happen.
Such was the case of a recent project where we had to implement authentication and session management on a small web application that would serve as an API gateway into other system components. The interesting part was that there was no database to store accounts, and credentials would have to be validated against an external web service.
Luckily for us, the Devise gem is customizable enough to be able to fulfill this requirement via custom authentication strategies. With custom authentication strategies, one can implement completely bespoke authentication logic while still enjoying a lot of the features that Devise offers out of the box.
In this article, we’re going to walk through doing just that. Let’s get started.
To learn more about this capability of Devise, and how it relates to the underlying Warden concepts, here are some interesting sources:
Setting up a new Rails project
If not, you can follow the official docs for installing Ruby.
Either way, once you have your environment ready, we can go ahead and start setting up our new Rails project.
First, install the rails gem:
$ gem install rails
Then, make sure you are located in the directory where you want to create the new project and do:
$ rails new . --minimal
--minimalis an option to
rails newadded in version 6.1 that disables a lot of default features and gems. You can learn more about it here.
Now, we need to add the Devise gem by adding this to your
And then running the following to install it in the project:
$ bin/rails generate devise:install
Let’s also create a “home” page which we will point the app root route to. Run:
$ bin/rails generate controller Pages home --no-helper
That will create a new
PagesController with a
home action and its corresponding view.
Next, in the
config/routes.rb file, replace the
get 'pages/home' line created by the previous command with
Also, like I said at the beginning, we won’t use a database for storing accounts or validating credentials. So, we can comment out this line on the Devise initializer at
Devise.setup do |config| # ... # ==> ORM configuration ## require 'devise/orm/active_record' # ... end
Now we have a strong enough foundation to get started. Run
bin/rails server and navigate to the URL that the Puma development server produced and you should see this:
Defining the User model
For Devise to work, it needs to be associated with a model class from our app that represents user accounts. Normally, this would be some
ActiveRecord::Base derived class, like most conventional implementations. For us, though, this will not work because, like I said at the beginning, we don’t have a database. Neither accounts nor credentials will be stored locally, but rather in an external web service.
So what we do instead is define a class that looks enough like an ActiveRercord such that Devise can work with it. Thanks to Rails' modularity, this is easy, as we only need to include a few modules into said class. Here’s what our
User model class ends up looking like:
# app/models/user.rb class User include ActiveModel::API include ActiveModel::Validations extend ActiveModel::Callbacks extend Devise::Models define_model_callbacks :validation attr_accessor :email, :password end
As you can see, all we have done is
extend various modules as well as define the validation callbacks. This will augment the class with the APIs necessary for Devise to be able to interact with it properly. We also define email and password fields which will be our credentials.
You can read more about
Now that this class is available, in order to tell Devise that it needs to work with it, we add the following line to the
Rails.application.routes.draw do # ... + devise_for :users end
Implementing the custom authentication strategy
Now that Devise is hooked up, let’s finally implement the custom authentication strategy. For this we will take three steps:
- Write a component that implements the authentication logic.
- Write a component that implements the serialization logic.
- Register the new strategy in the initializer and model class.
Let’s get started with step 1. Here’s what the class looks like:
# app/custom_auth/devise/strategies/custom_authenticatable.rb # Based on https://gist.github.com/madtrick/3917079 module Devise module Strategies # The class needs to inherit from Devise::Strategies::Authenticatable which # implements most of the underlying logic for auth strategies in Devise. # You can read the code here: # https://github.com/heartcombo/devise/blob/main/lib/devise/strategies/authenticatable.rb class CustomAuthenticatable < Authenticatable # This is the method called by Warden to authenticate a user. # More info in https://github.com/wardencommunity/warden/wiki/Strategies#authenticate def authenticate! if credentials_valid? # Signals Warden that the authentication was successful. # Expects an instance of the model class that Devise is configured to work with. # This should be the user account that matches the provided credentials. success!(validated_user) else # Signals Warden that the authentication failed. fail! end end private # Returns a boolean indicating whether the provided credentials are valid. # This is where you can implement any bespoke logic to do so: Read a file, # call a service, validate a one-time-use token, etc. # # authentication_hash is provided by the base class and includes all the # fields included in the login form. def credentials_valid? authentication_hash[:email] == "email@example.com" && authentication_hash[:password] == "password" end def validated_user # mapping.to is a reference to the model class that Devise is configured # to use that represents user accounts. In this case, it's the User class. mapping.to.new( email: authentication_hash[:email], password: authentication_hash[:password] ) end end end end
Feel free to read through that code and the comments for some explanations on what each piece does. There are a few key points to go over.
First of all, the class needs to inherit from
Devise::Strategies::Authenticatable. This is the base class that Devise provides for us to create custom authentication strategies. The most important method that we need to define is
authenticate!. This method is invoked by the powers that be (i.e. Warden) to run the actual authentication logic. After calling it, Warden expects it to invoke either
fail!, depending on the authentication results, and the rest of the system will act accordingly.
success! expects an instance of User being passed as an argument. This User instance represents the account that matches the given credentials.
authenticate! method is where Devise and Warden give you the ability to fully customize how to authenticate the given credentials. I’ve chosen to implement a simple check against hardcoded values for our example here, but this can be anything. In my real world use case, I had to make a call to an external web service, but you could also check a file, validate a token with some algorithm, anything that’s possible with Ruby and Rails really.
The next step is to implement the logic to serialize and deserialize the instance of User that represents the logged-in account to and from the Rails session store. This is encapsulated in a very simple module that looks like this:
# app/custom_auth/devise/models/custom_authenticatable.rb # Based on https://gist.github.com/madtrick/3916999 module Devise module Models module CustomAuthenticatable extend ActiveSupport::Concern module ClassMethods # Recreates a resource from session data. # # It takes as many params as elements in the array returned in # serialize_into_session. def serialize_from_session(email) new(email:) end # Returns an array with the data from the user that needs to be # serialized into the session. def serialize_into_session(user) [user.email] end end end end end
And this one is very self-explanatory. There’s a
serialize_into_session method that receives the User instance for the logged-in account and returns an array of the individual fields of it that we want Devise to include in the session data that persists across requests. And there’s also a
serialize_from_session method that receives the fields from the serialized array as individual parameters and has to return a new User instance.
And finally, we need to register the new strategy and put it to work. We do that by adding these lines in the Devise initializer at
Devise.setup do |config| # ... config.warden do |manager| manager.strategies.add(:custom, Devise::Strategies::CustomAuthenticatable) manager.default_strategies(scope: :user).unshift(:custom) end Devise.add_module :custom_authenticatable, controller: :sessions, route: :session, strategy: :custom # ... end
Here, we’ve registered our new custom authentication strategy with Warden and added it as a default. We’ve also declared a new Devise “module” (via the
Devise.add_module method) that will use that strategy (as given by the
strategy: parameter) and act as part of the sessions-related functionality (as given by the
route: parameters). This means that, by adding this module to our User model class, Devise will render the routes necessary for session handling. Namely,
sign_out. Like this:
$ bin/rails routes Prefix Verb URI Pattern Controller#Action new_user_session GET /users/sign_in(.:format) devise/sessions#new user_session POST /users/sign_in(.:format) devise/sessions#create destroy_user_session DELETE /users/sign_out(.:format) devise/sessions#destroy
Now, all that’s left is adding the module to the
User class at
class User # ... devise :custom_authenticatable, authentication_keys: [:email, :password] end
Note how we specify the
authentication_keys to be
password, the same fields that
Devise::Strategies::CustomAuthenticatable uses to run the validation logic, and are defined as attributes in
User. These can be anything really. The nice thing about using
password in particular is that Devise’s default views and controllers already work with these fields, so no additional changes and configurations need to be done if we follow that convention.
With all this, we’re basically plugging some of our code right into the middle of Devise’s logic for authentication and leveraging the rest of the functionality provided by it, like automatic views and controllers and such. As we’ll see next.
Implementing session management
Now that we’ve laid out all the piping that we need, the rest of the work to add support for sessions to our little test app is actually just business as usual with Devise.
By adding the
custom_authenticatable Devise module to the User class, and the
devise_for :users line on the
config/routes.rb file, the session management actions are already in. So all we have to do is add a few links to actually use them. Adding the following snippet on
app/views/layouts/application.html.erb inside the
<body> element will suffice:
<% if user_signed_in? %> <span>Hello <%= current_user.email %></span> <%= button_to "Log out", destroy_user_session_path, method: :delete %> <% else %> <%= link_to "Log in", new_user_session_path %> <% end %> <p class="notice"><%= notice %></p> <p class="alert"><%= alert %></p>
This should be very familiar if you’ve worked with Devise before. If not, there are more details here.
All this does is show a link to the log in page when there’s no user logged in; and a hello message along with a log out button when there is.
Run the app with
bin/rails server, click the “Log in” link, and you’ll see Devise’s default log in page. Type in the hardcoded credentials from
Devise::Strategies::CustomAuthenticatable and submit the form to see something like this:
We’ve just been redirected back to the root and are greeted and shown a result message courtesy of Devise.
And that’s it! We’ve developed a pretty simple custom authentication strategy for Devise that’s very straightforward. It does not use a database at all!
The important thing though, is that we can use this pattern to implement any number of more complex procedures. And we’ve done so in a way that does not lock us out of leveraging the rest of Devise’s out-of-the-box capabilities like automatic controllers and views.