
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):
bundle exec rails generate migration add_column_role_to_users role:integerThen run the migration:
bundle exec rails db:migrateAnd then modify the user model to include the roles we defined:
# app/models/user.rb
class User < ApplicationRecord
# User role
enum :role, %i[writer editor]
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
endOne 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:
bundle exec rails g scaffold Post title body:text user:referencesWe 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:
# 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
endThe 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:
# app/models/user.rb
class User < ApplicationRecord
has_many :posts
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
enum :role, %i[writer editor]
endFinally, add some users, each with a different role:
# 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 roleThen seed the database:
bundle exec rails db:seedOur app now has:
- Authentication set up using Devise.
- Two defined user roles of “writer” and “editor”.
- A
Postmodel.
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:
# Gemfile
gem 'pundit'Then in the terminal, run the command:
bundle installAlternatively, run the following command:
bundle add punditSince 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:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Pundit::Authorization
endAnd finally, generate a base policy class all other policies will inherit from:
bundle exec rails g pundit:installWhich gives you the following base policy class:
# 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
endIf 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
Postmodel is accessed is calledPostPolicy. - An
attr_reader: this takes two arguments — the first is auser, 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/policiesfolder.
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:
bundle exec rails g pundit:policy postThis gives us the following generic policy class that inherits from the base policy we generated earlier:
# 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
endNow add access rules for the writer role (these also override any rules that are inherited from the base policy class):
# 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
endHere, 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:
# 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
endTo test this, log in as an editor and try to create a post. Doing this raises an error:
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:
# 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
endThe unauthorized user is sent back where they came from, with a flash message explaining what happened:

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:
# 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
endThen we’ll authorize access to the resource in the posts controller, like so:
# 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
# ...
endScoping 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:
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
# ...
def permitted_attributes
if user.editor?
[:title, :body, :excerpt]
else
[:title, :body]
end
end
endThen, modify the permitted params block in the controller:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
# ...
private
def post_params
params.require(:post).permit(policy(@post).permitted_attributes)
end
endThe 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:
bundle add cancancanLike 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:
bundle exec rails g cancan:abilityWhich generates this class object (the generated file’s instructional comments are trimmed here):
# app/models/ability.rb
class Ability
include CanCan::Ability
def initialize(user)
end
endThe 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:
# app/models/ability.rb
can actions, subjects, conditionsAs an example:
# 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
endThen, in the controller, check if an access rule exists for a particular action — using our example, the edit action:
# 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:
You are not authorized to access this page. (CanCan::AccessDenied)
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:
# 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
endDoing 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:

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:
#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:
# 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
endThe 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:
# 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
endBecause 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
| Pundit | CanCanCan | |
|---|---|---|
| Where rules live | One policy class per resource, under app/policies | A single Ability class (splitting it up is possible, not the default) |
| Style | Plain Ruby query methods (create?, destroy?) | A can/cannot rule DSL |
| View helpers | policy(@post).edit? | can? :edit, @post |
| Fetching permitted records | policy_scope(Post), backed by policy Scope classes | Post.accessible_by(current_ability), derived from can conditions |
| Strong parameters | Built in, via permitted_attributes | Not built in — combine with your own params logic |
| Testability | Policies are plain objects — unit test them without Rails | Test the single Ability class; rules for all resources live together |
| Rails 8.1 support | Yes (Pundit 2.5.2, verified) | Yes (CanCanCan 3.6.1, verified) |
| Devise integration | Works out of the box with current_user | Works 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.
Published , Updated
Wondering what you can do next?
- Subscribe to our Ruby Magic newsletter and never miss an article again.
- Start monitoring your Ruby app with AppSignal.
- Share this article on social media

Aestimo Kirina
Our guest author Aestimo is a full-stack developer, tech writer/author and SaaS entrepreneur.
All articles by Aestimo KirinaBecome our next author!
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!


