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
endThis 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:installAdd 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
endThe 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
endThe 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
endThen 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
endView 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
endController 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
endThat 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
endUse 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
endCanCanCan 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
endAction 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_bymaps 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
endDo 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
endIntentional 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_scopeoraccessible_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
403or redirect intentionally for unauthorized writes. - Return
404for 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
- Pundit documentation
- CanCanCan documentation
- Action Policy documentation
- Rails Security Guide
- Rails 8.1.3 release announcement
At Saeloun, we help teams design and review authorization for Rails applications.
