Rails Pundit Authorization: Production Patterns for Multi-Tenant SaaS Apps
A SaaS founder called me last October because their pen test had come back with a finding that ruined his weekend. A logged-in user from Acme Corp could fetch invoices belonging to Globex by changing the ID in the URL. Classic IDOR. The fix was not the hard part — adding a tenant scope to one query took ten minutes. The hard part was that the same shape of bug existed in twenty-three other places, and nobody had any way to know that without reading every controller. They had no authorization layer. They had current_user checks scattered across helpers, controllers, and a few service objects. We rewrote the whole thing on top of Pundit over the next three weeks.
After nineteen years of Rails I have seen plenty of authorization frameworks come and go, and Pundit is the one I keep reaching for in multi-tenant SaaS. Not because it is fancy, but because it is small enough to fit in your head and rigid enough to give you a place to put every rule. This is the Rails Pundit authorization guide I wish more teams had read before their first SOC 2 audit.
Why Rails Pundit Authorization Wins for SaaS
CanCanCan puts every rule in one big Ability class. That works until it doesn’t, and then you have a 600-line file that nobody wants to touch. Action Policy is excellent and slightly more powerful, but Pundit’s plain-old-Ruby model is easier to explain to a new hire on day one.
Pundit is also boring in the right way. Each resource gets a policy object. Each policy has methods that match controller actions. There are no DSLs, no callbacks, no surprises. When you want to know who can update an invoice, you open InvoicePolicy and read the update? method. That is the entire mental model.
For multi-tenant SaaS the killer feature is policy_scope: a uniform place to say “this user can only ever see records inside their tenant.” Once you wire it in, the IDOR bug from my opening story becomes structurally impossible across every controller that uses it.
# Gemfile
gem "pundit", "~> 2.4"
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Pundit::Authorization
after_action :verify_authorized, except: :index, unless: :devise_controller?
after_action :verify_policy_scoped, only: :index, unless: :devise_controller?
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 verify_authorized and verify_policy_scoped after-actions are the part most tutorials skip and the part you absolutely need. They raise in test if a controller action forgot to call authorize or policy_scope. That single line is what turns Pundit from a convention into a guarantee.
Setting Up Rails Pundit Authorization for Multi-Tenancy
The first thing every multi-tenant Pundit setup needs is a clear definition of “tenant.” For most SaaS apps it is Account or Organization or Workspace. Pick one name and stick with it. I will use Account here.
Every record that belongs to a tenant should have an account_id. Every controller should have access to current_account — usually derived from current_user.account or from a subdomain. The job of the policy layer is to make the combination of current_user, current_account, and the record under inspection produce a yes-or-no answer.
# app/policies/application_policy.rb
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
raise Pundit::NotAuthorizedError, "must be logged in" unless user
@user = user
@record = record
end
def index? = false
def show? = false
def create? = false
def new? = create?
def update? = false
def edit? = update?
def destroy? = false
class Scope
attr_reader :user, :scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
raise NotImplementedError, "#{self.class} must implement #resolve"
end
end
end
Default-deny is non-negotiable. If a developer forgets to override a method, the answer is no. I have seen teams flip these defaults to true “for convenience” during early development, and then ship to production three weeks later. Do not do this.
Tenant-Scoped Policies in Practice
Here is what an InvoicePolicy looks like for a SaaS with a tenant model and a few roles per user (admin, member, viewer).
# app/policies/invoice_policy.rb
class InvoicePolicy < ApplicationPolicy
def index? = same_account?
def show? = same_account?
def create? = same_account? && (user.admin? || user.member?)
def update? = same_account? && (user.admin? || (user.member? && record.draft?))
def destroy? = same_account? && user.admin? && record.draft?
def permitted_attributes
base = %i[customer_id due_on currency notes]
base += %i[status approved_at] if user.admin?
base
end
class Scope < ApplicationPolicy::Scope
def resolve
scope.where(account_id: user.account_id)
end
end
private
def same_account?
record.account_id == user.account_id
end
end
Two things to notice. First, every method that touches a record starts with same_account?. That is the load-bearing piece. Without it, an admin in Acme could update an invoice belonging to Globex just because they are an admin somewhere. Second, permitted_attributes is a Pundit feature most teams discover too late — it lets the policy decide which fields a controller is allowed to mass-assign, instead of duplicating the logic in params.permit(...).
Wiring it up in the controller stays clean:
# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
before_action :set_invoice, only: %i[show update destroy]
def index
@invoices = policy_scope(Invoice).order(created_at: :desc).page(params[:page])
end
def show
authorize @invoice
end
def create
@invoice = Invoice.new(permitted_attributes(Invoice).merge(account: current_account))
authorize @invoice
@invoice.save ? redirect_to(@invoice) : render(:new, status: :unprocessable_entity)
end
def update
authorize @invoice
if @invoice.update(permitted_attributes(@invoice))
redirect_to @invoice
else
render :edit, status: :unprocessable_entity
end
end
private
def set_invoice
@invoice = Invoice.find(params[:id])
end
end
Notice that set_invoice does not scope by account. It does not need to. The authorize @invoice call invokes show? or update?, both of which check same_account?. If a user from another tenant guesses the ID, Pundit raises NotAuthorizedError and the rescue in ApplicationController redirects them out. The IDOR is dead.
For index you do the same job from the other end: policy_scope(Invoice) runs the Scope.resolve method, which restricts the relation to the current user’s account. Cross-tenant data leakage in list endpoints is the most common SaaS bug, and policy_scope is the single best mitigation I know.
Role-Based Access Control with Rails Pundit Authorization
Pundit does not ship a roles system, which is a feature, not a bug. Use whatever fits your app. For most clients I use enum role: { viewer: 0, member: 1, admin: 2 } on User, with helper methods like member_or_admin?. For enterprise customers who need per-resource roles, I use a join table — Memberships(user_id, account_id, role) — and inject the membership into the policy:
# app/policies/application_policy.rb
def membership
@membership ||= user.memberships.find_by(account_id: record.account_id)
end
def role
membership&.role
end
def admin? = role == "admin"
def member? = role == "member" || admin?
def viewer? = role == "viewer" || member?
The viewer? is true for members and admins, member? is true for admins. This kind of role hierarchy keeps each policy method short. update? becomes same_account? && member? instead of same_account? && (admin? || member?). Less code, fewer chances to forget a role.
For multi-tenant SaaS where one user belongs to multiple accounts (the SaaS dream), the Memberships model is a must. Do not put role on User. You will regret it the first time someone is an admin in one workspace and a viewer in another.
Avoiding N+1 in Pundit Policy Scopes
The first time you ship Pundit to production you will discover that your policies hit the database too often. A page that shows a hundred invoices, each with a small <%= link_to "Edit", edit_invoice_path(invoice) if policy(invoice).update? %>, will instantiate a hundred policy objects and call update? a hundred times. If update? consults current_user.memberships.find_by(...), that is a hundred queries.
The fix is to memoize at the controller or view level. Pundit already memoizes the policy instance per record via policy(record), so the call only happens once per record. The membership lookup is the slow part. Cache it.
# app/policies/application_policy.rb
def membership
@membership ||= user.memberships
.find_by(account_id: record.account_id) || NullMembership.new
end
For list pages, preload memberships once:
def index
@memberships_by_account = current_user.memberships.index_by(&:account_id)
@invoices = policy_scope(Invoice).includes(:customer)
end
And consult @memberships_by_account[invoice.account_id] from a view helper. It feels like a small thing. On a real customer dashboard it cut response time from 700ms to 90ms.
Testing Pundit Policies Without Boilerplate
Policies are the easiest part of a Rails app to test exhaustively, and the place where exhaustive tests pay back the most. I write a permutation test for every policy: each role, each ownership combination, each action.
# spec/policies/invoice_policy_spec.rb
require "rails_helper"
RSpec.describe InvoicePolicy do
subject { described_class.new(user, invoice) }
let(:account) = build_stubbed(:account)
let(:other) = build_stubbed(:account)
let(:invoice) = build_stubbed(:invoice, account: account, status: "draft")
context "admin in same account" do
let(:user) { build_stubbed(:user, account: account, role: :admin) }
it { is_expected.to permit_actions(%i[index show create update destroy]) }
end
context "member in same account" do
let(:user) { build_stubbed(:user, account: account, role: :member) }
it { is_expected.to permit_actions(%i[index show create update]) }
it { is_expected.to forbid_actions(%i[destroy]) }
end
context "admin in different account" do
let(:user) { build_stubbed(:user, account: other, role: :admin) }
it { is_expected.to forbid_actions(%i[index show create update destroy]) }
end
end
The pundit-matchers gem gives you permit_actions and forbid_actions. The cross-tenant context — “admin in different account” — is the test you must never skip. It is the regression test for the IDOR that started this post.
Production Traps Worth Knowing
A few things that have bitten clients in the wild:
- Forgetting
verify_authorizedin API controllers. DifferentApplicationControllerfor the API namespace, never wired up the after-action, six months of unauthorized endpoints. Add it everywhere or inherit from a shared base. policy_scopeon a relation that is already filtered.policy_scope(Invoice.where(status: "paid"))works.policy_scope(account.invoices)works too, but it is redundant — the scope re-applies the account filter. Pick one path and be consistent.- Background jobs. Pundit is request-scoped. In jobs you do not have
current_user. Pass the user explicitly and callPundit.policy(user, record).update?yourself. Never assume. - Admin backdoors. “Superadmin” roles that bypass
same_account?. Tempting for support tooling. Lethal if a token leaks. Use a separate admin namespace, with its own policies that are honest about cross-tenant access. - Migration of legacy auth. When replacing scattered
current_userchecks with Pundit, do it controller by controller and keep the old check in place until tests pass for the new one. Big-bang rewrites of authorization end in incidents.
For deeper cuts on the surrounding stack, see inheriting a legacy Rails codebase for how to make these changes safely, Rails technical due diligence for what an auditor will look for, and Rails feature flags for rolling out a new authorization layer gradually.
FAQ
Is Pundit better than CanCanCan for Rails authorization?
For multi-tenant SaaS with more than a handful of resources, yes. CanCanCan centralizes everything in Ability, which becomes unmaintainable past a few dozen rules. Pundit’s one-policy-per-resource scales linearly with your domain instead of quadratically.
How does Pundit compare to Action Policy?
Action Policy is faster, has built-in caching and pre-checks, and is the right choice if you have hundreds of policy calls per request. Pundit is simpler and more familiar to new Rails hires. I default to Pundit for SaaS under a few million monthly requests; I switch to Action Policy when policy lookups show up in profiles.
How do I handle Pundit authorization in Rails API mode?
Same setup, but use head :forbidden in user_not_authorized instead of redirecting, and make sure your API base controller includes Pundit::Authorization. Run verify_authorized everywhere — APIs are where IDORs love to hide.
Can Pundit handle attribute-level authorization?
Yes, via permitted_attributes. Define which fields each role can mass-assign in the policy, and call permitted_attributes(Model) in the controller instead of params.require(...).permit(...). The policy becomes the single source of truth for both row-level and field-level access.
Need help locking down authorization in your Rails SaaS without breaking everything that already works? TTB Software specializes in Rails security, multi-tenant architecture, and incremental migrations of critical infrastructure. We’ve been doing this for nineteen years.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Rails Puma Tuning: Workers, Threads, Memory and Concurrency for Production Performance
April 24, 2026
Rails Solid Cache: Database-Backed Caching in Rails 8 Without Redis or Memcached
April 23, 2026
Postgres Autovacuum Tuning for Rails: Stop Table Bloat and Transaction ID Wraparound in Production
April 21, 2026
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation