Rails Authorization Patterns: Pundit, CanCanCan, and Action Policy

Authentication proves identity. Authorization enforces rules.

Most production authorization bugs in Rails are not syntax mistakes. They are missing tenant scopes, global find calls, or new controller actions that shipped without a policy check.

Rails 8.1.3 is current as of April 28, 2026, and Rails 8 ships with a good authentication generator. That solves only the first half. After sign in, we still need a clear rule for every sensitive read and write.

TL;DR

  • Authentication is not authorization.
  • Scope collections before loading records.
  • Treat Model.find(params[:id]) as suspicious in multi-tenant code.
  • Use view checks only for display. Controllers and APIs still need policy checks.
  • Re-check authorization in jobs when permissions can change after enqueue.
  • Test the other-account case, not only the happy path.

The Rule

For most Rails apps, authorization should answer three questions:

  • Can this user perform this action?
  • Can this user see this record?
  • Can this user see this collection?

The third question is where many apps fail. Checking update? on one record is not enough if the index page, export endpoint, API response, or background job can still read records from another account.

A Small Role Check

For a small internal tool, simple role checks can be enough.

# app/models/user.rb
class User < ApplicationRecord
  enum :role, { member: 0, manager: 1, admin: 2 }

  def can_manage_users?
    admin?
  end
end
# app/controllers/admin/users_controller.rb
class Admin::UsersController < ApplicationController
  before_action :require_admin!

  def index
    @users = User.order(:email_address)
  end

  private

  def require_admin!
    return if Current.user&.admin?

    redirect_back_or_to root_path, alert: "Access denied"
  end
end

This is fine when the app has a few roles and no record-level rules.

It breaks down when rules start to depend on:

  • the current account
  • ownership
  • workflow state
  • billing plan
  • team membership
  • feature flags
  • admin impersonation

At that point, authorization should move out of controllers.

Pundit

Pundit uses plain Ruby policy objects. Its strength is explicitness: we can see the policy class, test the policy class, and force controllers to call it.

# Gemfile
gem "pundit"
bundle install
bin/rails g pundit:install

Add Pundit to the application controller.

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

  after_action :verify_pundit_authorization

  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

  private

  def verify_pundit_authorization
    if action_name == "index"
      verify_policy_scoped
    else
      verify_authorized
    end
  end

  def user_not_authorized
    redirect_back_or_to root_path, alert: "Access denied"
  end
end

The after_action is development discipline. It does not replace authorization, but it catches the common mistake: adding a controller action and forgetting the policy call. Reviewers should fail the change when verify_authorized or verify_policy_scoped is missing.

Scope Before Find

The safest Rails authorization habit is simple: scope before find.

# Bad: finds by global id first
def show
  @invoice = Invoice.find(params[:id])
  authorize @invoice
end

# Good: cannot find another account's invoice
def show
  @invoice = policy_scope(Invoice).find(params[:id])
  authorize @invoice
end

The first version can leak whether a record exists. The second version treats cross-account access as not found.

For multi-tenant Rails apps, the policy scope should begin with the tenant boundary.

# app/policies/invoice_policy.rb
class InvoicePolicy < ApplicationPolicy
  def show?
    account_member?
  end

  def update?
    account_admin? && record.draft?
  end

  def destroy?
    account_admin? && record.draft?
  end

  class Scope < ApplicationPolicy::Scope
    def resolve
      return scope.none unless user

      scope.where(account_id: user.account_memberships.select(:account_id))
    end
  end

  private

  def account_member?
    user&.account_memberships.exists?(account_id: record.account_id)
  end

  def account_admin?
    user&.account_memberships.exists?(
      account_id: record.account_id,
      role: :admin
    )
  end
end

Then controllers stay boring.

# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
  def index
    @invoices = policy_scope(Invoice).order(created_at: :desc)
  end

  def show
    @invoice = policy_scope(Invoice).find(params[:id])
    authorize @invoice
  end

  def update
    @invoice = policy_scope(Invoice).find(params[:id])
    authorize @invoice

    if @invoice.update(invoice_params)
      redirect_to @invoice, notice: "Invoice updated"
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def invoice_params
    params.require(:invoice).permit(:due_on, :notes)
  end
end

View checks should use the same policy. They are for hiding buttons, not for protecting the endpoint.

<% if policy(@invoice).update? %>
  <%= link_to "Edit", edit_invoice_path(@invoice) %>
<% end %>

<% if policy(@invoice).destroy? %>
  <%= button_to "Delete", invoice_path(@invoice), method: :delete %>
<% end %>

Pundit Tests

Policy tests should cover both allow and deny cases.

# test/policies/invoice_policy_test.rb
require "test_helper"

class InvoicePolicyTest < ActiveSupport::TestCase
  test "account admin can update a draft invoice" do
    user = users(:acme_admin)
    invoice = invoices(:acme_draft)

    assert InvoicePolicy.new(user, invoice).update?
  end

  test "account member cannot update a draft invoice" do
    user = users(:acme_member)
    invoice = invoices(:acme_draft)

    refute InvoicePolicy.new(user, invoice).update?
  end

  test "admin from another account cannot read invoice" do
    user = users(:other_account_admin)
    invoice = invoices(:acme_draft)

    refute InvoicePolicy.new(user, invoice).show?
  end

  test "scope only returns invoices from user's accounts" do
    user = users(:acme_admin)

    records = InvoicePolicy::Scope.new(user, Invoice.all).resolve

    assert_includes records, invoices(:acme_draft)
    refute_includes records, invoices(:other_account_draft)
  end
end

Controller or request tests should catch cross-account access.

# test/controllers/invoices_controller_test.rb
require "test_helper"

class InvoicesControllerTest < ActionDispatch::IntegrationTest
  test "user cannot access another account's invoice" do
    sign_in users(:acme_admin)

    get invoice_path(invoices(:other_account_draft))

    assert_response :not_found
  end
end

That test matters more than a happy-path policy test. It verifies the route, the lookup, the policy scope, and the controller behavior together.

CanCanCan

CanCanCan centralizes authorization rules in an Ability class. It can also load and authorize RESTful resources automatically.

# Gemfile
gem "cancancan"
bundle install
bin/rails g cancan:ability
# app/models/ability.rb
class Ability
  include CanCan::Ability

  def initialize(user)
    user ||= User.new

    account_ids = account_ids_for(user)
    admin_account_ids = admin_account_ids_for(user)

    can :read, Invoice, account_id: account_ids

    can [:update, :destroy], Invoice,
      account_id: admin_account_ids,
      status: "draft"
  end

  private

  def account_ids_for(user)
    return [] unless user.persisted?

    user.account_memberships.pluck(:account_id)
  end

  def admin_account_ids_for(user)
    return [] unless user.persisted?

    user.account_memberships.admin.pluck(:account_id)
  end
end

Use accessible_by when loading collections.

class InvoicesController < ApplicationController
  def index
    @invoices = Invoice.accessible_by(current_ability).order(created_at: :desc)
  end

  def show
    @invoice = Invoice.accessible_by(current_ability).find(params[:id])
    authorize! :read, @invoice
  end
end

CanCanCan is a good fit when the team wants centralized rules and the app is close to standard REST controllers.

The tradeoff is that Ability can become a large conditional file. When rules become hard to read, split abilities by domain or move back toward policy objects.

Action Policy

Action Policy is a modern policy-based option. It keeps the policy-object shape, adds useful Rails integration, and supports features such as authorization reasons, aliases, scoping, and caching for expensive checks.

# Gemfile
gem "action_policy"
# app/policies/invoice_policy.rb
class InvoicePolicy < ApplicationPolicy
  def show?
    account_member?
  end

  def update?
    account_admin? && record.draft?
  end

  relation_scope do |relation|
    if user
      relation.where(account_id: user.account_memberships.select(:account_id))
    else
      relation.none
    end
  end

  private

  def account_member?
    user&.account_memberships.exists?(account_id: record.account_id)
  end

  def account_admin?
    user&.account_memberships.exists?(
      account_id: record.account_id,
      role: :admin
    )
  end
end

Action Policy is worth considering when the app needs richer authorization feedback, policy aliases, or cached checks. For many Rails teams, Pundit is still easier to introduce because the API is smaller.

Which One Should We Choose?

Start with the smallest rule that is still explicit.

Use simple role checks when:

  • the app is internal
  • authorization is mostly admin vs non-admin
  • there are no tenant or ownership rules

Use Pundit when:

  • the app has record-level authorization
  • the team values explicit controller calls
  • policies should be easy to test in isolation
  • tenant scoping must be visible in code review

Use CanCanCan when:

  • rules are naturally centralized
  • controllers are mostly RESTful
  • accessible_by maps well to the app’s data model

Use Action Policy when:

  • policies need richer failure reasons
  • expensive checks need caching
  • the team wants policy objects with more framework support

My default for a serious Rails product is Pundit: explicit, review-friendly, and small enough that the team will actually read the policies.

Use CanCanCan when REST resources and accessible_by map cleanly to the app. Use Action Policy when the app needs failure reasons, aliases, or cached checks. Otherwise, extra framework surface area is not free.

APIs and Background Jobs

Authorization bugs often bypass HTML controllers.

API endpoints, exports, webhooks, and background jobs need the same rule: start from an authorized scope, then authorize the action.

# app/controllers/api/invoices_controller.rb
class Api::InvoicesController < Api::BaseController
  def show
    invoice = policy_scope(Invoice).find(params[:id])
    authorize invoice

    render json: InvoiceSerializer.new(invoice)
  end
end

Do not pass a bare record id to a job and let the job perform a global lookup when the work depends on the actor’s permissions.

# app/jobs/export_invoice_job.rb
class ExportInvoiceJob < ApplicationJob
  def perform(user_id, invoice_id)
    user = User.find_by(id: user_id)

    unless user
      Rails.logger.info("Skipping invoice export because user #{user_id} no longer exists")
      return
    end

    invoice = InvoicePolicy::Scope.new(user, Invoice.all).resolve.find(invoice_id)

    raise Pundit::NotAuthorizedError unless InvoicePolicy.new(user, invoice).show?

    InvoiceExporter.new(invoice).call
  end
end

Intentional re-check: permissions can change between enqueue and perform.

For jobs triggered by system events, there may not be a user. In that case, make the actor explicit: SystemUser, Current.account, or a narrowly scoped service object. Hidden global access is the silent bug. Make the actor explicit.

Authorization Checklist

Before merging a Rails authorization change, stop and verify:

  • Scope collections with policy_scope or accessible_by.
  • Scope record lookup before find.
  • Authorize every write action.
  • Keep view checks separate from controller checks.
  • Check tenant boundaries on admin paths.
  • Run export and report endpoints through the same authorization model.
  • Keep background jobs inside the actor’s scope.
  • Keep API authorization at least as strict as HTML controller authorization.
  • Return 403 or redirect intentionally for unauthorized writes.
  • Return 404 for cross-tenant reads when record existence should not leak.
  • Test owner, member, admin, and another-account users.

The most common bug is this:

Invoice.find(params[:id])

In a multi-tenant app, that should almost always make us pause.

Prefer:

policy_scope(Invoice).find(params[:id])

or:

Current.account.invoices.find(params[:id])

then authorize the record.

Resources


At Saeloun, we help teams design and review authorization for Rails applications.

Contact us for Rails security consulting

Need expert help with Rails?

Saeloun is a Rails Foundation Contributing Member helping teams modernize, upgrade, scale, and maintain production Rails applications.

Our Expertise

  • Rails contributors
  • 500+ Technical Articles
  • Production Rails consulting
  • Performance Optimization

Services

  • Rails application development
  • Code Audits
  • Rails upgrades
  • Team Augmentation

Need help on your Ruby on Rails or React project?

Join Our Newsletter