May 24, 2026
Policy objects in Rails: Pundit vs Action Policy vs rolling your own
When current_user.admin? checks stop scaling, you reach for a policy object. I compare Pundit, Action Policy, and a plain PORO — and the bar each one earns.
Every Rails app I’ve worked on hits the same wall around month six: current_user.admin? shows up in three controllers, then a view, then a job, and someone adds an editor role and the conditionals start to nest. That’s the moment to extract a policy object. The question is which kind.
I’ve shipped all three approaches in production. Here’s how I think about the choice.
The wall you hit
It usually looks like this in a controller:
# illustrative
def update
@post = Post.find(params[:id])
if current_user.admin? || @post.user_id == current_user.id
@post.update(post_params)
else
head :forbidden
end
end
One file, fine. Six files, a smell. Sixty files and a new role coming in next sprint, an emergency. The fix is the same in all three approaches: move the question “can this user do this thing to this record?” into one object whose only job is to answer.
Option A: roll your own
A policy object is a PORO with a constructor that takes a user and a record, and a method per action. That’s it.
# illustrative
class PostPolicy
def initialize(user, post)
@user = user
@post = post
end
def update?
@user.admin? || @post.user_id == @user.id
end
def destroy? = update?
end
Wired up by hand in the controller:
# illustrative
def update
@post = Post.find(params[:id])
raise NotAuthorized unless PostPolicy.new(current_user, @post).update?
@post.update(post_params)
end
You write one base class for the raise NotAuthorized rescue, one rescue_from in ApplicationController, and you’re done. No dependency. No DSL. Total surface area you control: maybe 80 lines including the base class.
The bar to roll your own: you have fewer than ~15 policies, you don’t need scoping (filtering collections by what the user can see), and your team is small enough that conventions live in heads.
Option B: Pundit
Pundit is the same PORO pattern with conventions baked in: authorize @post, policy_scope(Post), PostPolicy::Scope for collection filtering, and an include Pundit::Authorization in the controller. The whole gem is small enough to read in one sitting.
What you get over rolling your own:
- A
Scopeconvention that’s actually useful — filtering an index page by what the current user can see is the part that bites you when you DIY. authorizeraises a specificPundit::NotAuthorizedErrorthat pairs withrescue_from.- A view helper,
policy(@post).update?, that keeps your templates clean.
What it costs: one dependency, one indirection layer, and a generator that creates files in app/policies/. The DSL is so minimal it barely qualifies as a DSL.
The bar for Pundit: you’ve got a real authorization surface — at least a dozen models with non-trivial rules — and you want collection scoping without writing it yourself.
Option C: Action Policy
Action Policy is what I reach for when the rules get genuinely complicated. It’s Pundit-shaped on the outside but the internals are different in three ways that matter:
- Pre-checks. You can declare
pre_check :allow_adminsonce and have every action in the policy short-circuit on it. With Pundit you re-implement that condition in every method or extract a private helper. - Reasons. A failed authorization call returns why it failed (which check tripped). For an admin UI that has to surface “you can’t edit this because the post is locked” instead of a generic 403, this saves real time.
- Caching. Policy results are memoized per request out of the box. With Pundit you’d add it yourself the day a view calls
policy(@post).update?six times for one record.
It also has first-class testing helpers and namespaced policies (different rules for the API vs the admin panel against the same record).
The cost: more concepts. The README is longer. Onboarding a new dev takes an extra hour because they have to learn what a pre-check is and when memoization fires.
The bar for Action Policy: you have role hierarchies, multi-tenant rules, or you’ve been bitten by a Pundit codebase where every policy starts with the same five lines of admin-bypass.
How I’d actually decide
Three questions:
1. Do you need collection scoping? If yes, skip your own. The Scope pattern is where DIY policies start to ossify into bespoke half-ORMs. Both Pundit and Action Policy do this well.
2. Do you have shared pre-conditions across most actions? Things like “admins always pass” or “the resource must be in a non-archived state for any write.” If yes, Action Policy’s pre-checks are worth the extra concepts. If no, Pundit is lighter.
3. Is your authorization a product feature or a guard rail? A guard rail is “block 403s.” A product feature is “show the user why they can’t do the thing, surface available actions in the UI.” Action Policy’s reasons are a real edge for the second case.
For a brand-new app I default to Pundit. The PORO shape is honest about what’s happening and the gem stays out of your way. I’ve moved exactly one app from Pundit to Action Policy in five years, and that was when we added a customer-facing admin panel that needed to surface failure reasons.
Migration paths
The good news: all three are interchangeable on the call site. policy(@post).update? looks the same whether the policy is a hand-rolled PORO, a Pundit policy, or an Action Policy policy. So:
- DIY → Pundit: rename your base class, inherit from
ApplicationPolicy, add theScopeinner class where you need it, swapraise NotAuthorizedforauthorize @post. Maybe a day for ten policies. - Pundit → Action Policy: there’s a migration guide in the Action Policy docs and the file shape is close enough that most policies need only the
class PostPolicy < ApplicationPolicyline changed. Pre-checks you add as you find duplication.
The path that’s painful is the other direction — DIY → Action Policy directly. You’ll spend more time understanding the gem than you will moving the code. Go through Pundit first, even mentally, to feel which conventions you actually need.
What I’d skip
CanCanCan. I shipped it on three projects between 2014 and 2018 and the central Ability class always grew into a 500-line conditional that no one wanted to touch. The per-policy pattern wins for the same reason service objects beat fat models: one file, one concern, one place to look. CanCanCan still works, but it’s the wrong shape for anything beyond a small admin app.
The real lesson
Authorization in Rails isn’t hard. It’s the second-most-postponed extraction after service objects, and the cost of waiting is the same: by the time you do it, you have ten places to update instead of two. Pick a tool that matches your scale today and migrate when the bar moves. Don’t pre-buy Action Policy for an app that has three roles and four protected actions.
The first time you write current_user.admin? || resource.user_id == current_user.id in a second file, that’s the cue. Extract then.