A JavaScript developer's guide to Rails: Authorization with Pundit

Coming from a JavaScript background, I’m used to handling permissions with simple if statements. Maybe a global “roles” object, or a quick check on the frontend that hides buttons if the user isn’t an admin. Working with Rails and Pundit forced me to think differently about authorization as a system.

Understanding the request flow and where helper methods come from would’ve saved me a lot of time upfront.

Philosophy of Pundit

At its core, a Pundit Policy answers the question:

Can \$USER take \$ACTION on \$RESOURCE?

A Policy Scope answers:

Scope down \$COLLECTION to only the ones \$USER can access.

These two layers keep authorization clean and explicit:

What belongs in Pundit:

  • Can this user edit contacts from other teams?
  • Can admins see all records, but agents only see their own?
  • Is this user allowed to destroy this resource?

What doesn’t belong in Pundit:

  • Default sorting (.order(created_at: :desc))
  • Business filters (.where(status: 'active'))
  • Pagination logic
  • Eager loading associations

Keep authorization separate from querying concerns. Your scopes should answer “who can see what,” not “how should we display it.”

How Pundit Actually Works: The Request Flow

Before diving into helper methods and syntax, let’s trace a request from start to finish. This is the foundation that makes everything else fall into place.

Scenario: User tries to edit a contact

Step 1: Request comes in

# User clicks "Edit Contact #42"
# Rails routes to: ContactsController#edit

Step 2: Controller loads the record and calls authorize

class ContactsController < ApplicationController
  def edit
    @contact = Contact.find(params[:id])  # Load contact #42
    authorize @contact                     # Ask Pundit: "Can this user edit this contact?"
    # ... rest of the action
  end
end

Step 3: Pundit looks up the policy

Behind the scenes, Pundit:

  1. Looks at the class name: @contact is a Contact object
  2. Finds the matching policy: ContactPolicy
  3. Looks at the controller action: edit action → calls edit? method

Step 4: Pundit creates a policy instance

# Pundit does this automatically:
policy = ContactPolicy.new(current_user, @contact)

Step 5: Pundit calls the appropriate method

# Pundit calls:
allowed = policy.edit?

# Which runs your code in ContactPolicy:
class ContactPolicy < ApplicationPolicy
  def edit?
    user.admin? || record.user_id == user.id
  end
end

Step 6: Pundit makes the decision

# If edit? returns true:
#   → Controller continues, user sees the edit form

# If edit? returns false:
#   → Pundit raises Pundit::NotAuthorizedError
#   → Rails shows an error page (or you handle it)

Visual Flow

User Request
    ↓
Controller: "authorize @contact"
    ↓
Pundit: "What class is @contact?" → Contact
    ↓
Pundit: "Find ContactPolicy"
    ↓
Pundit: "What action?" → edit
    ↓
Pundit: "Call ContactPolicy.new(current_user, @contact).edit?"
    ↓
Your Code: user.admin? || record.user_id == user.id
    ↓
Returns true or false
    ↓
true → Allow action
false → Raise error

Concrete Example with Real Data

Let’s say the current user is Sarah (ID: 5, admin: false) and the contact being edited is John (ID: 42, user_id: 5).

# Controller
@contact = Contact.find(42)  # John
authorize @contact

# Pundit does:
policy = ContactPolicy.new(sarah, john)
allowed = policy.edit?

# Inside edit? method:
def edit?
  user.admin? || record.user_id == user.id
  # sarah.admin? || john.user_id == sarah.id
  # false || (5 == 5)
  # false || true
  # => true ✅ ALLOWED
end

If Sarah tries to edit a contact owned by someone else (user_id: 99):

def edit?
  user.admin? || record.user_id == user.id
  # false || (99 == 5)
  # false || false
  # => false ❌ DENIED → Pundit::NotAuthorizedError
end

The key insight: Pundit is just a fancy way to call your Ruby methods with the right arguments. Instead of cluttering your controller with authorization logic, you write a clean policy class and Pundit orchestrates the call. Your authorization logic resides in a single testable location.

Where Do user and record Come From?

When you write:

class ContactPolicy < ApplicationPolicy
  def edit?
    user.admin? || record.user_id == user.id
  end
end

Where do user and record come from?

They come from the ApplicationPolicy base class. When you install Pundit, it generates this file at app/policies/application_policy.rb:

class ApplicationPolicy
  attr_reader :user, :record

  def initialize(user, record)
    @user = user
    @record = record
  end
end

When you call authorize(@contact) in your controller:

  1. Pundit creates: ContactPolicy.new(current_user, @contact)
  2. The initializer sets:
    • @user = current_user (the logged-in user)
    • @record = @contact (the object you’re authorizing)
  3. The attr_reader makes them accessible as user and record in your methods

So user is the current logged-in user (whatever current_user returns), and record is the specific object you passed to authorize.

How Scopes Work

Scopes follow the same pattern but for filtering collections instead of authorizing individual records.

The Flow for Scopes

# Controller
def index
  @contacts = policy_scope(Contact)
end

Pundit finds ContactPolicy::Scope, creates ContactPolicy::Scope.new(current_user, Contact), calls .resolve, and returns the filtered ActiveRecord relation.

class ContactPolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      if user.admin?
        scope.all  # Admin sees everything
      else
        scope.where(user_id: user.id)  # Users see only their own contacts
      end
    end
  end
end

In scopes, user is the current logged-in user and scope is the model class or relation you passed in (e.g., Contact).

Why class Scope < Scope Looks Weird

You’ll see this pattern and wonder why it looks circular:

class ContactPolicy < ApplicationPolicy
  class Scope < Scope  # ← Why inherit from itself?
    def resolve
      # ...
    end
  end
end

The second Scope isn’t the same as the first. class Scope creates a new class called ContactPolicy::Scope, and < Scope makes it inherit from ApplicationPolicy::Scope, which itself inherits from Pundit::Scope. It’s just Ruby class inheritance, but nested classes make it look circular.

The shorthand works because you’re already inside ContactPolicy, which inherits from ApplicationPolicy, so Ruby knows what Scope means in that context.

Pundit::Scope defines the base structure:

module Pundit
  class Scope
    attr_reader :user, :scope

    def initialize(user, scope)
      @user = user
      @scope = scope
    end
  end
end

Setting Up Pundit in Your Controllers

To get access to authorize and policy_scope methods, include Pundit in your ApplicationController:

class ApplicationController < ActionController::Base
  include Pundit
end

Now in any controller:

class ContactsController < ApplicationController
  def index
    @contacts = policy_scope(Contact)  # Filters the collection
  end

  def show
    @contact = Contact.find(params[:id])
    authorize @contact  # Checks if user can view this specific contact
  end

  def edit
    @contact = Contact.find(params[:id])
    authorize @contact  # Checks if user can edit this specific contact
  end

  def update
    @contact = Contact.find(params[:id])
    authorize @contact

    if @contact.update(contact_params)
      redirect_to @contact
    else
      render :edit
    end
  end
end

Use authorize(@record) for show, edit, update, and destroy actions to check whether this user can perform the action on this specific record. Use policy_scope(Model) for index actions to return only the records this user is allowed to see.

Class-Level Authorization

Sometimes you need to authorize an action before you have a specific record:

# Can this user create a new contact?
def new
  authorize Contact  # Checks ContactPolicy#new?
  @contact = Contact.new
end

# Can this user export all contacts?
def bulk_export
  authorize Contact, :export?  # Checks ContactPolicy#export?
  # ... export logic
end

Your policy handles this:

class ContactPolicy < ApplicationPolicy
  def new?
    user.present?  # Only logged-in users can create contacts
  end

  def export?
    user.admin?  # Only admins can bulk export
  end
end

When you call authorize(Contact), the record inside your policy will be the Contact class itself, not an instance. Write separate methods like new? or create? for class-level actions.

Enforce Authorization Checks Automatically

Make Rails fail loudly if you forget to authorize or scope. Add these enforcement checks to your ApplicationController:

class ApplicationController < ActionController::Base
  include Pundit

  # Ensure authorize is called on non-index actions
  after_action :verify_authorized, except: :index

  # Ensure policy_scope is called on index actions
  after_action :verify_policy_scoped, only: :index
end

Now, if you forget to call authorize or policy_scope, Rails will raise an error in development. This catches authorization bugs before they reach production.

When you need to skip authorization intentionally:

def public_landing
  skip_authorization
  # ... public content
end

def health_check
  skip_authorization
  render json: { status: 'ok' }
end

If you manually scope records instead of using policy_scope:

def custom_index
  @contacts = Contact.where(published: true).where(user_id: current_user.id)
  skip_policy_scope
end

This enforcement pattern transforms authorization from “remember to do this” to “the framework ensures you do this.”

Understanding Test Failures

If your tests throw Pundit::NotAuthorizedError, Pundit::AuthorizationNotPerformedError, or Pundit::PolicyScopingNotPerformedError after adding enforcement checks, the system is working as designed. Check if you signed in a user, called authorize or policy_scope, or need to call skip_authorization or skip_policy_scope for intentionally public or manually-scoped actions.

Reusing Scopes Outside Controllers

Scopes aren’t just for controllers. You can reuse them anywhere:

# In a background job
class GenerateReportJob
  def perform(user)
    contacts = ContactPolicy::Scope.new(user, Contact).resolve
    # ... generate report
  end
end

# In a service object
class ContactExporter
  def initialize(user)
    @user = user
  end

  def export
    contacts = ContactPolicy::Scope.new(@user, Contact).resolve
    # ... export logic
  end
end

You can also include Pundit to get access to the policy_scope helper method:

class ContactExporter
  include Pundit

  def initialize(user)
    @user = user
  end

  def export
    contacts = policy_scope(Contact)  # Uses @user automatically
    # ... export logic
  end

  private

  def pundit_user
    @user  # Tell Pundit which user to authorize with
  end
end

You can also use policy scopes within other Pundit policy methods:

class ContactPolicy < ApplicationPolicy
  def index?
    # Check if user has access to any contacts
    allowed_contacts = Scope.new(user, Contact).resolve
    allowed_contacts.exists?
  end

  class Scope < ApplicationPolicy::Scope
    def resolve
      if user.admin?
        scope.all
      else
        scope.where(owner: user)
      end
    end
  end
end

This keeps your access rules centralized and DRY.

Conclusion

When a request comes in, the controller calls authorize(@record). Pundit finds the matching policy, creates an instance, and calls the appropriate method. Your code decides by returning true or false, and Pundit enforces the decision by allowing the action or raising an error.

Quick reference: authorize(@record) checks if user can perform the action on a specific record. policy_scope(Model) filters collections to what user can see. In policies, user is the current logged-in user, record is the object being authorized, and scope is the model/relation being filtered.

The shift from frontend permission checks to backend policy objects feels weird at first. But once it clicks, you’ll appreciate having all your authorization rules in one testable place instead of scattered across controllers and views.

About thoughtbot

We've been helping engineering teams deliver exceptional products for over 20 years. Our designers, developers, and product managers work closely with teams to solve your toughest software challenges through collaborative design and development. Learn more about us.