Short on time? Get the code as a Rails template on GitHub. It's a one-line install.
I love Rails. Its rich ecosystem of tried and tested functionality makes it a great choice for building backends for everything from SaaS apps to microservices.
In this post, we'll look at how to build an API-only Rails authentication system which you might connect to a client-side frontend or app.
We'll be using Devise for Authentication, JSON:API for API responses, and Rspec for testing. Here's the structure we're aiming for:
Download the flow chart as a PDF
We'll create our own session and registration controller which will extend the default Devise controllers for those same actions. On successful login / signup, we return an Authorization
header with a Bearer TOKEN
value. We'll use this token to authenticate subsequent requests.
On the client-side, we will send our Authorization: Bearer TOKEN
header when we make our API requests. Devise then authenticates our requests, either rendering the controller action or returning a 401
error as appropriate.
Step 1. Rails API setup
Install Rails
Start by creating a new Rails App with the --api
flag. This gives you a limited set of Rails features without the whole shebang you might use for a full-stack web application:
$ rails new your_app_name --api
Install Devise for authentication
We'll use Devise for user authentication. You could say it's overkill to use Devise for a relatively simple API authentication task, but it's long-standing place as the de facto Rails authentication solution is hard to argue with:
To install Devise, add the following to your Gemfile:
gem 'devise'
Then run bundle install
, followed by the generator:
$ rails generate devise:install
Finally, run rails db:migrate
. That will be enough to get you up and running, but check out the Devise documentation for more options.
Install devise-jwt for token-based authentication with Devise
A big thanks to Adam Mazur whose post on installing devise-jwt I'm summarising here.
Devise doesn't include token authentication out of the box, so we'll use the devise-jwt gem. Add it to your Gemfile:
gem 'devise-jwt', '~> 0.7.0'
bundle install
then add a secret key in the Devise initializer. (Pro tip: you can generate secrets on the command line in Rails with bundle exec rake secret
). This is used to generate signed JWT tokens. We also add login/logout request routes here, together with our token expiration date.
# config/initializers/devise.rb
Devise.setup do |config|
# ...
config.jwt do |jwt|
jwt.secret = ENV['DEVISE_JWT_SECRET_KEY']
jwt.dispatch_requests = [
['POST', %r{^/api/login$}]
]
jwt.revocation_requests = [
['DELETE', %r{^/api/logout$}]
]
jwt.expiration_time = 1.day.to_i
end
end
We will revoke JWT tokens using the DenyList
strategy. This means we create a new table in our database to store expired tokens, and cross-reference tokened requests to check they are valid. Create the migration:
$ rails generate migration CreateJwtDenylist
The migration creates a simple table with an indexed string column which contains the expired token, together with an exp
DateTime column.
# db/migrate/xxxxx_create_jwt_denylist.rb
class CreateJwtDenylist < ActiveRecord::Migration[6.0]
def change
create_table :jwt_denylist do |t|
t.string :jti, null: false
t.datetime :expired_at, null: false
end
add_index :jwt_denylist, :jti
end
end
Run rails db:migrate
, then create a model to implement the revocation strategy:
# app/models/jwt_denylist.rb
class JwtDenylist < ApplicationRecord
include Devise::JWT::RevocationStrategies::Denylist
self.table_name = 'jwt_denylist'
end
Finally, we need to tell our User model that we're going to authenticate with JWT, and our revocation strategy:
# app/models/user.rb
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
end
Install rack-cors to allow cross-origin requests
Rails blocks cross-origin requests out of the box. To access our API from a different URL, we need to install the rack-cors
gem. Add it to your Gemfile:
gem 'rack-cors'
Then bundle install
. Finally, create an initializer. We allow all origins here, but you should limit origins to a whitelist of domains in production.
# config/initializers/rack_cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '*',
headers: :any,
methods: [:get, :post, :patch, :put, :options]
end
end
Install jsonapi-rails for easy JSON:API serialisation
We'll use the jsonapi-rails gem to return our models as valid JSON:API objects. Add it to your Gemfile:
gem 'jsonapi-rails'
Then bundle install
.
Define the serialized version of your User
model in the SerializableUser
class. This tells the controller which attributes to return in a JSON:API response for a given user. We only want email
to start with, so we'll add that as an attribute
, and a link to self
.
Learn more about the JSON:API format as a whole, or the ruby-specific jsonapi-rb gem.
# app/serializable/SerializableUser.rb
class SerializableUser < JSONAPI::Serializable::Resource
type 'users'
attributes :email
link :self do
@url_helpers.api_user_url(@object.id)
end
end
Step 2. RSpec test setup
Install rspec-rails
Our tests will use RSpec. Start by adding rspec-rails to the test section of your Gemfile:
group :test do
gem 'rspec-rails'
#...
end
Then bundle install
. Next, run the RSpec generator on the command line:
$ rails generate rspec:install
Install factory_bot_rails for fixtures
We'll use factory_bot_rails to create fixtures to use in our tests. Add to the test section of your Gemfile:
group :test do
gem 'factory_bot_rails'
#...
end
Then bundle install
. Lastly, create a simple user factory:
# spec/factories/users.rb
FactoryBot.define do
factory :user do
end
end
Install faker for test data
Faker helps us create real-looking test data. Add to the test group in your Gemfile:
group :test do
gem 'faker'
#...
end
Then bundle install
.
Add UserHelpers support module
To simplify creating users during our tests, we will add a couple of helper methods:
# spec/support/user_helpers.rb
require 'faker'
require 'factory_bot_rails'
module UserHelpers
def create_user
FactoryBot.create(:user,
email: Faker::Internet.email,
password: Faker::Internet.password
)
end
def build_user
FactoryBot.build(:user,
email: Faker::Internet.email,
password: Faker::Internet.password
)
end
end
Note that FactoryBot.create
actually creates the user in the database, whereas FactoryBot.build
just gives us the attributes.
Require all support files, and include our new UserHelpers
module in RSpec.configure
:
# spec/rails_helper.rb
# ...
# Requires supporting ruby files
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
RSpec.configure do |config|
# ...
config.include UserHelpers
# ...
end
Now we can use create_user
and build_user
in our specs.
Install jsonapi-rspec for response matching
The jsonapi-rspec gem provides some neat matchers for JSON:API responses. Add to the test group in your Gemfile:
group :test do
gem 'jsonapi-rspec'
#...
end
Then bundle install
. Finally, require the gem in your rails_helper, then include it in your RSpec config.
# spec/rails_helper.rb
# ...
require 'jsonapi/rspec'
RSpec.configure do |config|
# ...
config.include JSONAPI::RSpec
# ...
end
Add ApiHelpers support module
To complete our test set up, we'll create a support file to cut down on repetition when working with API responses:
#spec/support/api_helpers.rb
module ApiHelpers
def json
JSON.parse(response.body)
end
def login_with_api(user)
post '/api/login', params: {
user: {
email: user.email,
password: user.password
}
}
end
end
As with our UserHelper
, ApiHelper
will be automatically require
-d in our rails_helper
. All we have to do is include it in RSpec.configure
:
# spec/rails_helper.rb
# ...
# Requires supporting ruby files
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
RSpec.configure do |config|
# ...
config.include ApiHelpers
# ...
end
Allow users to Sign Up
Now our test framework is all set up, let's start TDD-ing. We'll kick off with our routes.
All routes are prefixed with /api
. To avoid conflicts with Devise's own namespacing logic we'll manually set our authentication routes in path_names
. We'll also default to a json response format while we're at it.
All other API controllers go under namespace :api
, and are stored in app/controllers/api/
folder. We'll just add a users#show action for now.
# config/routes.rb
Rails.application.routes.draw do
namespace :api, defaults: { format: :json } do
resources :users, only: %w[show]
end
devise_for :users,
defaults: { format: :json },
path: '',
path_names: {
sign_in: 'api/login',
sign_out: 'api/logout',
registration: 'api/signup'
},
controllers: {
sessions: 'sessions',
registrations: 'registrations'
}
end
Add a JSON:API response helper to ApplicationController
We'll use this to render JSON:API responses in our Devise controllers.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
def render_jsonapi_response(resource)
if resource.errors.empty?
render jsonapi: resource
else
render jsonapi_errors: resource.errors, status: 400
end
end
end
Create a Registration Spec
We use request specs, not controller specs. Unlike controller specs, request specs load the whole stack and return the Authorization headers which are critical to test.
# spec/controllers/api/registrations_controller_spec.rb
require 'rails_helper'
describe RegistrationsController, type: :request do
let (:user) { build_user }
let (:existing_user) { create_user }
let (:signup_url) { '/api/signup' }
context 'When creating a new user' do
before do
post signup_url, params: {
user: {
email: user.email,
password: user.password
}
}
end
it 'returns 200' do
expect(response.status).to eq(200)
end
it 'returns a token' do
expect(response.headers['Authorization']).to be_present
end
it 'returns the user email' do
expect(json['data']).to have_attribute(:email).with_value(user.email)
end
end
context 'When an email already exists' do
before do
post signup_url, params: {
user: {
email: existing_user.email,
password: existing_user.password
}
}
end
it 'returns 400' do
expect(response.status).to eq(400)
end
end
end
Create a Registrations Controller
We inherit from Devise's own Registrations controller, so not much to add here. We attempt to save the user on signup, then return our custom response with the render_jsonapi_response
method we added in ApplicationController
earlier on.
# app/controllers/registrations_controller.rb
class RegistrationsController < Devise::RegistrationsController
def create
build_resource(sign_up_params)
resource.save
sign_up(resource_name, resource) if resource.persisted?
render_jsonapi_response(resource)
end
end
Run foreman run rake
again and you should see your tests pass.
Allow users to Log In & Log Out
As with our RegistrationController
, we'll write our (failing) spec before writing the code to make it pass:
# spec/controllers/api/sessions_controller_spec.rb
require 'rails_helper'
describe SessionsController, type: :request do
let (:user) { create_user }
let (:login_url) { '/api/login' }
let (:logout_url) { '/api/logout' }
context 'When logging in' do
before do
login(user)
end
it 'returns a token' do
expect(response.headers['Authorization']).to be_present
end
it 'returns 200' do
expect(response.status).to eq(200)
end
end
context 'When password is missing' do
before do
post login_url, params: {
user: {
email: user.email,
password: nil
}
}
end
it 'returns 401' do
expect(response.status).to eq(401)
end
end
context 'When logging out' do
it 'returns 204' do
delete logout_url
expect(response).to have_http_status(204)
end
end
end
Create a Sessions Controller
Similarly to Registrations, we don't need to add much to the default Devise Sessions Controller. We'll send a response with the render_jsonapi_response
method we previously added to our ApplicationController
, and a 204
status when logging out. Other than that we're just inheriting everything from Devise.
# app/controllers/sessions_controller.rb
class SessionsController < Devise::SessionsController
private
def respond_with(resource, _opts = {})
render_jsonapi_response(resource)
end
def respond_to_on_destroy
head :no_content
end
end
Again, run foreman run rake
and you'll be off to the races.
Congratulations. You've just set up JWT-powered authentication. Next, let's create an authenticated route which uses our new setup.
Make an API Request
We're no longer inheriting from the Devise, so we will create a Base API controller to hold shared functionality.
As want to authorise all API requests, we use Devise's :authenticate_user!
. This means we will automatically authenticate all our API controller actions. Pretty neat.
# app/controllers/api/base_controller.rb
class Api::BaseController < ApplicationController
before_action :authenticate_user!
rescue_from ActiveRecord::RecordNotFound, with: :not_found
def not_found
render json: {
'errors': [
{
'status': '404',
'title': 'Not Found'
}
]
}, status: 404
end
end
We'll add a generic 404 handler while we're at it.
Create a User Controller Spec
We'll use the login_with_api method from our ApiHelpers
to log in before making each request. This ensures we set the necessary Authentication headers.
# spec/controllers/api/users_controller_spec.rb
require 'rails_helper'
describe Api::UsersController, type: :request do
let (:user) { create_user }
context 'When fetching a user' do
before do
login_with_api(user)
get "/api/users/#{user.id}", headers: {
'Authorization': response.headers['Authorization']
}
end
it 'returns 200' do
expect(response.status).to eq(200)
end
it 'returns the user' do
expect(json['data']).to have_id(user.id.to_s)
expect(json['data']).to have_type('users')
end
end
context 'When a user is missing' do
before do
login_with_api(user)
get "/api/users/blank", headers: {
'Authorization': response.headers['Authorization']
}
end
it 'returns 404' do
expect(response.status).to eq(404)
end
end
context 'When the Authorization header is missing' do
before do
get "/api/users/#{user.id}"
end
it 'returns 401' do
expect(response.status).to eq(401)
end
end
end
As before a foreman run rake
will show a failing test, so let's set up the controller:
It's just connecting the dots at this point. As we authenticate API requests and handle 404s in our base controller, so all we have to do is find the user and render the JSON:API response. Easy peasy:
# app/controllers/api/users_controller.rb
class Api::UsersController < Api::BaseController
before_action :find_user, only: %w[show]
def show
render_jsonapi_response(@user)
end
private
def find_user
@user = User.find(params[:id])
end
end
Conclusion
And that's it! It takes a bit of setup, but once you've done the legwork you've got yourself a flexible, test-driven authenticated API to use on the frontend of your choice.
Get the code in this post as a Rails template on GitHub.