Ruby

Pundit vs CanCanCan: Choosing a Rails Authorization Gem

Pundit vs CanCanCan: Choosing a Rails Authorization Gem

Pundit and CanCanCan are Ruby’s two leading authorization gems, and both run on Rails 8.1. Pundit uses one plain-Ruby policy class per resource, which scales better as rules grow; CanCanCan defines every rule in a single Ability class, which is faster to set up. Choose Pundit for larger apps, CanCanCan for small ones.

In this guide, we’ll build the same role-based permissions twice — first with Pundit’s policy classes, then with CanCanCan’s abilities — on a Rails 8.1 app, and finish with a feature-by-feature comparison and a verdict for each use case.

Setup and Prerequisites

We’ll use a simple Rails app featuring users and posts, with every snippet in this article verified on Rails 8.1. Users will be assigned an “editor” or “writer” role — a scenario that’s perfect for showcasing how authorization works.

Check out the code repos for our example app with:

Both repos were written against Rails 7; the code in this article has been updated for Rails 8.1.

Authorization builds on its companion subject, authentication: the process of registering users, logging them in, and tracking their session state. We won’t get into setting it up, as that’s outside the scope of this post. Our example app uses the Devise gem — if you’re new to it, our introduction to Devise for Ruby on Rails walks through the full setup.

A note on Rails 8’s authentication generator: since Rails 8, bin/rails generate authentication gives you a built-in alternative to Devise (the two are compared in the Devise guide). Either way, Rails generates authentication, not authorization — defining user roles and access rules remains your job, and that’s the layer Pundit and CanCanCan provide.

Neither gem creates your app’s user roles automatically when you install it, so we’ll set those up first.

Defining User Roles

With authentication in place and a user model set up, the next step is to decide what roles the app’s users will have. In our case, we’ll set out the following roles:

  • Writer: This user role will be able to create, edit, update, and delete their own posts. At the same time, a writer can also view other writers’ posts.
  • Editor: A user with the editor role can edit, update, view, and delete any user’s posts, but they cannot create their own posts.

Add a column to store a user’s role (using a migration to modify the user’s table):

Shell
bundle exec rails generate migration add_column_role_to_users role:integer

Then run the migration:

Shell
bundle exec rails db:migrate

And then modify the user model to include the roles we defined:

Ruby
# app/models/user.rb
 
class User < ApplicationRecord
  # User role
  enum :role, %i[writer editor]
 
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
end

One Rails 8.1 caution: enum takes the attribute name as a positional argument. The old keyword form — enum role: %i[writer editor] — raises ArgumentError: wrong number of arguments (given 0, expected 1..2), so update it if you’re copying from an older app.

Next, set up a Post model with title and body attributes, plus a reference to the user who writes the post:

Shell
bundle exec rails g scaffold Post title body:text user:references

We add the foreign key user_id into the Post model to associate every post that’s created with a particular user. Since we have already set up user authentication using Devise, we can modify the create method of the Posts controller to automatically set the user_id to the currently logged-in user:

Ruby
# app/controllers/posts_controller.rb
# ...
 
def create
  @post = current_user.posts.new(post_params) # automatically assign a post to the current user on creation
 
  respond_to do |format|
    if @post.save
      format.html { redirect_to @post, notice: "Post was successfully created." }
      format.json { render :show, status: :created, location: @post }
    else
      format.html { render :new, status: :unprocessable_content }
      format.json { render json: @post.errors, status: :unprocessable_content }
    end
  end
end

The failure branches render with status: :unprocessable_content — the symbol the Rails 8.1 scaffold generates for HTTP 422. Rack 3.2 deprecates the older :unprocessable_entity and warns that it will be removed, so update that too when copying from an older app.

We can also modify the User model to make sure it’s associated with the Post model:

Ruby
# app/models/user.rb
 
class User < ApplicationRecord
  has_many :posts
 
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
 
  enum :role, %i[writer editor]
end

Finally, add some users, each with a different role:

Ruby
# db/seeds.rb
 
User.create(email: 'writer1@example.com', password: 'example', password_confirmation: 'example', role: 0) # this creates a user with the writer role
User.create(email: 'writer2@example.com', password: 'example', password_confirmation: 'example', role: 0) # second writer
User.create(email: 'editor@example.com', password: 'example', password_confirmation: 'example', role: 1) # this creates a user with the editor role

Then seed the database:

Shell
bundle exec rails db:seed

Our app now has:

  • Authentication set up using Devise.
  • Two defined user roles of “writer” and “editor”.
  • A Post model.

That’s everything Pundit needs.

Authorization in Your Ruby App with Pundit

Pundit is an authorization library built around object-oriented architecture and plain Ruby classes. It gives you tools to build a solid authorization layer that can scale with your app.

Installing Pundit in Your Ruby App

Add the gem to your app’s Gemfile:

Ruby
# Gemfile
 
gem 'pundit'

Then in the terminal, run the command:

Shell
bundle install

Alternatively, run the following command:

Shell
bundle add pundit

Since authorization mostly deals with granting or denying access to controller resources, the next step is to add Pundit’s authorization module to the application controller:

Ruby
# app/controllers/application_controller.rb
 
class ApplicationController < ActionController::Base
  include Pundit::Authorization
end

And finally, generate a base policy class all other policies will inherit from:

Shell
bundle exec rails g pundit:install

Which gives you the following base policy class:

Ruby
# app/policies/application_policy.rb
 
class ApplicationPolicy
  attr_reader :user, :record
 
  def initialize(user, record)
    @user = user
    @record = record
  end
 
  def index?
    false
  end
 
  def show?
    false
  end
 
  def create?
    false
  end
 
  def new?
    create?
  end
 
  def update?
    false
  end
 
  def edit?
    update?
  end
 
  def destroy?
    false
  end
 
  class Scope
    def initialize(user, scope)
      @user = user
      @scope = scope
    end
 
    def resolve
      raise NoMethodError, "You must define #resolve in #{self.class}"
    end
 
    private
 
    attr_reader :user, :scope
  end
end

If you last generated this file with an older Pundit version, one detail has changed: the base Scope#resolve now raises NoMethodError rather than NotImplementedError when a policy forgets to define its own resolve.

Pundit is now set up.

Next, we’ll use policies to implement rules that define how each user role will access the Post resource.

Configuring Pundit Policies

In Pundit lingo, a “policy” is a plain Ruby class where you define all the rules for how a user role interacts with different resources.

These policies come with some notable features:

  • Each policy is named after an existing model, suffixed with the word “Policy”. For example, a policy defining how the Post model is accessed is called PostPolicy.
  • An attr_reader: this takes two arguments — the first is a user, specifically, the currently logged-in user — current_user — and the second argument is the model that you’d like to define authorization rules for, in our case, post.
  • Query methods that will map to the controller methods of the resource that has authorization rules set up. For organizational purposes, it’s best to have all policies under the app/policies folder.

Since we know what access rules our app needs, we’ll start with the writer role.

Defining a Pundit Policy for a Role

To begin with, we can outline the writer role’s access to the Post resource as follows:

  • Create their own posts
  • Edit and update their own posts
  • View (or read) their own posts as well as other users’ posts
  • Delete their own posts

Generate a new policy to control how posts are accessed:

Shell
bundle exec rails g pundit:policy post

This gives us the following generic policy class that inherits from the base policy we generated earlier:

Ruby
# app/policies/post_policy.rb
 
class PostPolicy < ApplicationPolicy
  # NOTE: Up to Pundit v2.3.1, the inheritance was declared as
  # `Scope < Scope` rather than `Scope < ApplicationPolicy::Scope`.
  # In most cases the behavior will be identical, but if updating existing
  # code, beware of possible changes to the ancestors:
  # https://gist.github.com/Burgestrand/4b4bc22f31c8a95c425fc0e30d7ef1f5
 
  class Scope < ApplicationPolicy::Scope
    # NOTE: Be explicit about which records you allow access to!
    # def resolve
    #   scope.all
    # end
  end
end

Now add access rules for the writer role (these also override any rules that are inherited from the base policy class):

Ruby
# app/policies/post_policy.rb
 
class PostPolicy < ApplicationPolicy
  # ...
  def create?
    @user.writer? # a writer is able to create a post
  end
 
  def edit?
    @user.writer? # a writer is able to edit a post
  end
 
  def update?
    @user.writer? # a writer can update a post
  end
 
  def destroy?
    @user.writer? # a writer can delete a post
  end
end

Here, we define what a writer can do when creating, editing, updating, and deleting posts, which are corresponding actions on the posts’ controller. One naming caution: Pundit infers the query method from the controller action, so authorize in a destroy action calls destroy?. A method named delete? is never called. With only delete? defined, deletion falls through to the inherited destroy?, which quietly denies every request.

These rules apply to posts in general and not necessarily to a writer’s own posts (we’ll get to that in the section on scopes).

Using a Policy in Pundit

To use a policy, call Pundit’s authorize method on the controller’s method where you want to check access rules. authorize instantiates the matching policy class and calls the query method named after the controller action.

For example, call authorize in the post controller’s create method:

Ruby
# app/controllers/posts_controller.rb
# ...
 
def create
  @post = current_user.posts.new(post_params)
  authorize @post
 
  respond_to do |format|
    if @post.save
      format.html { redirect_to @post, notice: "Post was successfully created." }
      format.json { render :show, status: :created, location: @post }
    else
      format.html { render :new, status: :unprocessable_content }
      format.json { render json: @post.errors, status: :unprocessable_content }
    end
  end
end

To test this, log in as an editor and try to create a post. Doing this raises an error:

text
not allowed to PostPolicy#create? this Post (Pundit::NotAuthorizedError)

Though this does what we want, showing an error page is not good for the user experience. Next, we’ll rescue the NotAuthorizedError and show the user something friendlier.

Rescuing From Pundit's NotAuthorizedError

Here’s the error from the failed authorize check, with its cause and fix.

not allowed to PostPolicy#create? this Post (Pundit::NotAuthorizedError)

Cause: An authorize call ran a policy query method that returned false — here, PostPolicy#create? for a user whose role can’t create posts. Since Pundit 2.4, the message names the policy class and query method, so you can read exactly which rule denied access. The same error appears when a policy doesn’t define the query method at all and the check falls through to the inherited default.

Fix: Rescue the error in ApplicationController and turn it into a redirect with a flash message. Pundit::NotAuthorizedError descends from StandardError, so a plain rescue_from handles it:

Ruby
# app/controllers/application_controller.rb
 
class ApplicationController < ActionController::Base
  include Pundit::Authorization
 
  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
 
  private
 
  def user_not_authorized
    flash[:alert] = "You are not authorized to perform this action."
    redirect_back(fallback_location: root_path)
  end
end

The unauthorized user is sent back where they came from, with a flash message explaining what happened:

Flash message shown after rescuing Pundit’s NotAuthorizedError

We now have a simple authorization system that handles a generalized permissions case.

But what if we want more fine-grained permissions? For this, we need Pundit’s scopes.

Pundit's Scopes

Pundit scopes are similar to ActiveRecord scopes. In the latter, you can use scopes to fetch records according to specific criteria. However, with Pundit’s scopes, you manage access to specific resources according to certain rules you set.

Say we want editors to be able to view and edit posts that are in “draft” status, and at the same time, allow writers to create, view, edit, update, and delete only their own posts.

We can start by editing the post policy to look like this:

Ruby
# app/policies/post_policy.rb
 
class PostPolicy < ApplicationPolicy
  class Scope < ApplicationPolicy::Scope
    def resolve
      if user.editor?
        # an editor can only access posts in "draft" status
        scope.where(published: false)
      else
        # can access a post if they are the author
        scope.where(user: user)
      end
    end
  end
 
  def show?
    @user.writer? || @user.editor?
  end
 
  def create?
    @user.writer?
  end
 
  def edit?
    @user.writer? || @user.editor?
  end
 
  def update?
    @user.writer? || @user.editor?
  end
 
  def destroy?
    @user.writer?
  end
end

Then we’ll authorize access to the resource in the posts controller, like so:

Ruby
# app/controllers/posts_controller.rb
 
class PostsController < ApplicationController
  # ...
  def index
    @posts = policy_scope(Post)
  end
 
  # GET /posts/1 or /posts/1.json
  def show
    @post = policy_scope(Post).find(params[:id])
  end
  # ...
end

Scoping with Pundit goes deeper than this — the Pundit documentation covers more advanced scope usage.

Using Pundit with Rails' Strong Parameters

By combining Pundit’s authorization rules with Rails’ strong parameters, you can lock down access to a resource’s attributes. Say you want editors to be the only ones with access to an excerpt field of the Post model. How would you go about it?

First, add an aptly-named block to the relevant policy:

Ruby
# app/policies/post_policy.rb
 
class PostPolicy < ApplicationPolicy
  # ...
  def permitted_attributes
    if user.editor?
      [:title, :body, :excerpt]
    else
      [:title, :body]
    end
  end
end

Then, modify the permitted params block in the controller:

Ruby
# app/controllers/posts_controller.rb
 
class PostsController < ApplicationController
  # ...
 
  private
 
  def post_params
    params.require(:post).permit(policy(@post).permitted_attributes)
  end
end

The excerpt attribute is now available to editors only.

Introducing CanCanCan for Your Ruby App

CanCanCan is an authorization library that uses an “ability” class to define who has access to what in a Rails app. Actual access control is achieved using an authorization module and various view helpers.

Installing CanCanCan

Install the gem by running:

Shell
bundle add cancancan

Like Pundit, CanCanCan lets you define all access rules within a plain Ruby class object — in this case, an “ability” class. Generate it with the following command:

Shell
bundle exec rails g cancan:ability

Which generates this class object (the generated file’s instructional comments are trimmed here):

Ruby
# app/models/ability.rb
 
class Ability
  include CanCan::Ability
 
  def initialize(user)
  end
end

The next step is defining access rules for our example Rails app in the ability class.

Defining and Checking CanCanCan Abilities

We’ll use the same user roles as in the Pundit example: writers and editors. A writer can create, edit, update, destroy their own posts, and view other writers’ posts; an editor can do everything except create a post of their own.

To use CanCanCan, first define what each user or role can access in the ability class, following this format:

Ruby
# app/models/ability.rb
 
can actions, subjects, conditions

As an example:

Ruby
# app/models/ability.rb
 
class Ability
  include CanCan::Ability
 
  def initialize(user)
    can :update, Post, user: user # With CanCanCan, the update action covers both the edit and update actions
  end
end

Then, in the controller, check if an access rule exists for a particular action — using our example, the edit action:

Ruby
# app/controllers/posts_controller.rb
 
# ...
def edit
  authorize! :edit, @post
end
# ...

With this in place, if we visit the edit post view as another writer, the check fails:

text
You are not authorized to access this page. (CanCan::AccessDenied)
CanCanCan access denied error

As we did with Pundit, we’ll rescue this error and show the user a better error page.

Handling CanCanCan’s “Access Denied” Errors

Whenever a resource is not authorized, CanCanCan will raise a CanCan::AccessDenied error.

You are not authorized to access this page. (CanCan::AccessDenied)

Cause: An authorize! check failed — no can rule in the ability class covers this user, action, and subject (or a cannot rule blocks it). The message is CanCanCan’s default, resolved through I18n, so it reads the same for every denied action until you customize it.

Fix: Catch the exception in ApplicationController — like Pundit’s error, CanCan::AccessDenied descends from StandardError, so rescue_from works the same way:

Ruby
# app/controllers/application_controller.rb
 
class ApplicationController < ActionController::Base
  rescue_from CanCan::AccessDenied do |exception|
    respond_to do |format|
      format.json { head :forbidden }
      format.html { redirect_to root_path, alert: exception.message }
    end
  end
end

Doing this makes for a good user experience. In this screenshot, the unauthorized user is redirected to the home page and shown a relevant flash message:

Handling CanCanCan’s access denied exception

You can even customize the error message shown to the user. CanCanCan looks up I18n keys from most to least specific — unauthorized.update.post for a denied update on a Post first, then unauthorized.update.all:

YAML
#config/locales/en.yml
en:
  unauthorized:
    update:
      all: "You're not authorized to %{action} %{subject}."
      post: "You're not authorized to update other writers' posts."

If your app serves XML as a response, or you want to dig deeper into handling the CanCan::AccessDenied exception, check out CanCanCan’s documentation.

A rescued authorization error is easy to miss in production: the user sees a polite redirect while a broken policy quietly locks everyone out. AppSignal’s Ruby error tracking groups Pundit::NotAuthorizedError and CanCan::AccessDenied by controller action, so a spike in denials shows up before your support inbox does.

Combining Multiple CanCanCan Abilities

You can define multiple access rules for a resource in the ability class. Taking the writer and editor roles, for example, we can do this:

Ruby
# app/models/ability.rb
 
class Ability
  include CanCan::Ability
 
  def initialize(user)
    can :update, Post, user: user # only a post's author/owner can update or edit a post
    can :read, Post # any user can read a post or list of posts (access both show and index actions)
    can :destroy, Post, user: user # only a post's author/owner can delete it
 
    return unless user.editor?
    cannot :create, Post # an editor role cannot create a post
    can :update, Post # an editor can update any post
  end
end

The question is, why would you want to do this?

With CanCanCan, you can define all access rules in one ability file.

Having all your rules in one file is convenient — you always know where a permission is defined.

However, if your app deals with many user roles or you have several resources that need authorization, the ability class can easily become too big and complex to handle. One way to handle this is by reorganizing the ability class to use method definitions, like so:

Ruby
# app/models/ability.rb
 
class Ability
  include CanCan::Ability
 
  def initialize(user)
    @user = user
 
    anyone_abilities
 
    if user.writer?
      writer_abilities
    elsif user.editor?
      editor_abilities
    end
  end
 
  private
 
  attr_reader :user
 
  def anyone_abilities
    can :read, Post
  end
 
  def writer_abilities
    can :create, Post
    can [:update, :destroy], Post, user: user # a writer's own posts only
  end
 
  def editor_abilities
    cannot :create, Post # an editor role cannot create a post
    can :update, Post # an editor can update any post
  end
end

Because the ability class is a plain Ruby object, storing the user in an instance variable (with an attr_reader) makes it available to every private ability method.

There’s more to CanCanCan than one article can cover — the CanCanCan documentation goes deeper.

To wrap up, we’ll compare the two libraries feature by feature and settle the choice.

Feature Comparison: Pundit vs. CanCanCan for Your Ruby App

PunditCanCanCan
Where rules liveOne policy class per resource, under app/policiesA single Ability class (splitting it up is possible, not the default)
StylePlain Ruby query methods (create?, destroy?)A can/cannot rule DSL
View helperspolicy(@post).edit?can? :edit, @post
Fetching permitted recordspolicy_scope(Post), backed by policy Scope classesPost.accessible_by(current_ability), derived from can conditions
Strong parametersBuilt in, via permitted_attributesNot built in — combine with your own params logic
TestabilityPolicies are plain objects — unit test them without RailsTest the single Ability class; rules for all resources live together
Rails 8.1 supportYes (Pundit 2.5.2, verified)Yes (CanCanCan 3.6.1, verified)
Devise integrationWorks out of the box with current_userWorks out of the box with current_user

Per use case, the choice comes down to this:

  • A growing app with many resources or per-resource rules → Pundit. Policies stay small, isolated, and unit-testable as the rule count climbs.
  • A small app where you want every rule readable in one place → CanCanCan. One ability file is faster to write and scan.
  • Devise on either side → no tiebreaker; both gems integrate with Devise equally well, as the examples in this article show.

Wrapping Up

We implemented the same role-based permissions with the two most popular authorization gems in the Ruby and Rails ecosystem: Pundit and CanCanCan.

Both gems are current, both run on Rails 8.1, and both can handle complex permission setups. The verdict is about fit, not capability: choose Pundit for a larger app — its per-resource policy classes scale and test better as rules grow — and CanCanCan for a smaller one, where a single ability class is quicker to set up and read.

Whichever gem you pick, authorization failures are worth watching once they reach production — AppSignal’s Rails integration tracks errors and performance across your whole app, including the denials your rescue_from handlers turn into polite redirects.

Happy coding!

P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Frequently asked questions

Should I use Pundit or CanCanCan?
Choose Pundit when your app has many resources or growing rules — one plain-Ruby policy class per resource keeps authorization organized and easy to test. Choose CanCanCan for smaller apps where defining every rule in a single Ability class is faster. Both pair well with Devise on Rails 8.1.
Do Pundit and CanCanCan work with Rails 8?
Yes. Pundit 2.5.2 and CanCanCan 3.6.1 both run on Rails 8.1, and every example in this article was verified against those versions. Rails 8’s built-in authentication generator only covers authentication, so an authorization gem is still the missing layer.
How do I rescue Pundit::NotAuthorizedError?
Add rescue_from Pundit::NotAuthorizedError in your ApplicationController and point it at a private method that sets a flash alert and redirects the user back. The error descends from StandardError, so a plain rescue_from handles it without any special configuration.
What is the difference between authentication and authorization?
Authentication verifies who a user is — registration, login, and session tracking, handled in Rails by Devise or the Rails 8 authentication generator. Authorization decides what an authenticated user may do, based on roles or rules, which is the job of gems like Pundit and CanCanCan.

Published , Updated

Wondering what you can do next?

  • Share this article on social media
Aestimo Kirina

Aestimo Kirina

Our guest author Aestimo is a full-stack developer, tech writer/author and SaaS entrepreneur.

All articles by Aestimo Kirina

Become our next author!

Find out more
$appsignal install

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal