Rails Authentication Routing Constraints Considered Harmful

This post is from the considered harmful series where we talk about wrong way of doing things. I mean the way of doing programming tasks that is highly popular, looks beneficial at first sight and even recommended by certain guides and tutorials but leads to unwanted results that you'll have either to deal with, or to live with.

And today we will talk about Rails authentication routing constraints.

The problem

When developing a Rails application it is often required to restrict access to some parts of it. The way to say: "This area is for signed in users only."

The solution

In a Rails world each controller level authentication library proudly presents routing constraints. Examples:

Devise

authenticated :user do
  resources :posts
end

Clearance

constraints Clearance::Constraints::SignedIn.new do
  resources :posts
end

Monban

constraints Monban::Constraints::SignedIn.new do
  resources :posts
end

You got it.

It works as follows – the Rails routing system allows you to set a condition on a route and if this condition is not met, the route will not work. An example of custom constraint from Rails guides:

class BlacklistConstraint
  def initialize
    @ips = Blacklist.retrieve_ips
  end

  def matches?(request)
    @ips.include?(request.remote_ip)
  end
end

Rails.application.routes.draw do
  get '*path', to: 'blacklist#index',
    constraints: BlacklistConstraint.new
end

If the constraint rejects the request, the routing system skips this route and proceeds down the list. When the end of the file is reached, it throws RoutingError, which results in the 404 error page: page not found.

You can read more at Rails Guides

Why it is bad

At first sight this approach seems to be a great alternative to writing in the controller something like before_action :authenticate_user!. It is all the same but you do not have to split it into separate controller files or choose a suitable base controller. Everything is nicely declaret in the most suitable place – in the routes file. And you treat it as a direct substitution of before_action :authenticate_user!.

However, this is not the case for methods like authenticate_user!. Non-logged-in users are not presented with a 404 error. Instead, they are redirected to the sign-in page with a message prompting them to sign in. Once they successfully sign in, they are often redirected to their intended destination.

On the other hand, routing constraints can only return a 404 error, which can be very inconvenient for users. If a user clears their browser session and attempts to access a familiar page, they will see a "Page Not Found" error. Moreover, the default "Page Not Found" page in Rails does not include navigation or layout, leaving the user confused about what has happened and where they are.

The client asks to fix this, you investigate the problem, remove routing constraints and specify authenticate_user! in the controllers.

What to do?

There are two answers: the easy one and the correct one.

Easy answer is "do not use routing constraints and just write authenticate_user!". This at least does not contradict with the common way of doing things and does not become a problem for the user. However there is a better way.

The correct answer

The point is that the check of access rights "guest or logged in user" – is just a particular case of access rights check in an app in general, i.e., authorization.

The solution is to use an authorization library, such as CanCan or Pundit. Everywhere and always do authorize! from this library. Most of them allow to require authorize! to be executed and throw exception if it does not happen.

Once you do it you also need to differentiate between situations of "the user can’t enter due to absence of a specific role" and "the user can’t enter because he is not logged in".

This way you have a smoothly working authentication system with good UX and in the meantime all access rights will be specified in a single file.

An example for CanCan:

In any controller:

class ArticlesController < ApplicationController
  load_and_authorize_resource

  def show
  end
end

You can just specify the resource but don’t have to upload it:

class ArticlesController < ApplicationController
  authorize_resource :articles

  def show
  end
end

Actually, in the single file with access rights:

class Ability
  include CanCan::Ability

  def initialize(user)
    if user # rights of logged in user
      can :manage, :articles
      # ...
    else # rights of unlogged user (guest)
      can :read, :home
    end
  end
end

Obviously you may also use CanCan according to its intended purpose, by checking the specific roles of already logged in users.

Now the gist: in case of access rights inconsistency you should redirect non-logged-in users to the login page but when a user is logged in but fails permission check, you should show "Access denied", maintaining the default behavior. An example for Devise and CanCan:

class ApplicationController < ActionController::Base
  rescue_from CanCan::AccessDenied do |exception|
    if user_signed_in?
      redirect_to root_url, :alert => exception.message # access denied
    else
      authenticate_user! # redirect to login page
    end
  end
end

And to force authorize call in order not to forget it:

class ApplicationController < ActionController::Base
  check_authorization
end

The conclusion

You should not use routing constraints for authentication with Rails. If you to detach authorization check from the specific controllers and pull it to a special permissions file, use an authorization library – it makes everything to fall into place. Which is no wonder, because this is an authorization.

In addition

  • Routing constraints on their own are great. Use them, just don't do it for authentication.
  • Authentication libraries that suggest routing constraints on their own are good. They suggest routing constraints because their competitors suggest them. Ignore it. This is not the reason you need this libraries – you want them for authentication.