Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 024 / 100

Authorization in Rails: a practical guide for developers

Who can do what.

Expona Admin14 min read

Introduction

Authentication tells you who a user is; authorization tells you what they can do. We often build features without a clear authorization strategy in place, then end up retrofitting permissions logic into controllers, models, and views when we realize users are accessing data they shouldn't. Authorization in Rails: a practical guide for developers walks through proven patterns that keep your permission logic maintainable and testable as your application grows.

Whether you're managing a small team or complex role-based hierarchies, the right approach scales without becoming a maintenance burden. In production systems handling e-commerce orders, SaaS multi-tenancy, and internal admin tools, we've seen authorization become a critical layer that affects both security and user experience. Let's explore the most practical tools and patterns we've used.

Pundit Policies Per Model

Pundit is the most widely adopted authorization library in Rails because it keeps policies as plain Ruby classes outside your models and controllers. Each model gets its own policy that defines who can perform which actions. This separation of concerns makes policies testable in isolation and easier to reason about as your permission rules grow more complex.

Start by installing the gem and generating a policy for a model:

ruby
# Gemfile
gem 'pundit', '~> 2.3'

# Terminal
bundle install
rails generate pundit:install
rails generate pundit:policy Post

Your generated policy file lives at app/policies/post_policy.rb and inherits from ApplicationPolicy:

ruby
class PostPolicy < ApplicationPolicy
  def index?
    true  # Everyone can list posts
  end

  def show?
    true  # Everyone can view a post
  end

  def create?
    user.present?  # Logged-in users can create
  end

  def update?
    user.present? && (record.author_id == user.id || user.admin?)
  end

  def destroy?
    user.present? && (record.author_id == user.id || user.admin?)
  end
end

The user and record variables are automatically available. The user is the current user from the session, and record is the instance being checked (e.g., a specific Post). In a multi-tenant SaaS application, you'd extend this pattern:

ruby
class InvoicePolicy < ApplicationPolicy
  def show?
    user.present? && user.company_id == record.company_id
  end

  def update?
    user.present? && user.company_id == record.company_id && (record.status == 'draft' || user.admin?)
  end

  def send_to_client?
    update? && record.all_line_items_validated?
  end

  def archive?
    user.present? && user.company_id == record.company_id && user.admin? && record.archived_at.nil?
  end
end

Notice how send_to_client? reuses authorization logic from update? and adds additional business constraints. This pattern keeps your policies DRY.

In your controller, include Pundit and authorize actions:

ruby
class PostsController < ApplicationController
  include Pundit::Authorization
  before_action :authenticate_user!, except: [:index, :show]
  before_action :set_post, only: [:show, :edit, :update, :destroy]

  def index
    @posts = Post.all
    authorize @posts, :index?
  end

  def show
    authorize @post, :show?
  end

  def edit
    authorize @post, :update?
  end

  def update
    authorize @post, :update?
    if @post.update(post_params)
      redirect_to @post, notice: 'Post updated.'
    else
      render :edit
    end
  end

  private

  def set_post
    @post = Post.find(params[:id])
  end
end

When authorization fails, Pundit raises Pundit::NotAuthorizedError. Handle this in your ApplicationController with a user-friendly error message and audit trail for security:

ruby
class ApplicationController < ActionController::Base
  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

  private

  def user_not_authorized(exception)
    Rails.logger.warn("Authorization failed for user #{current_user&.id}: #{exception.message}")
    
    flash[:alert] = "You are not authorized to perform this action."
    redirect_to(request.referrer || root_path)
  end
end

Use Pundit in views to conditionally render UI elements, but remember this is a UX enhancement, not a security measure:

ruby
<% if policy(@post).update? %>
  <%= link_to 'Edit', edit_post_path(@post) %>
<% end %>

<% if policy(@post).destroy? %>
  <%= link_to 'Delete', post_path(@post), method: :delete, data: { confirm: 'Are you sure?' } %>
<% end %>

In production systems, we've seen developers hide edit buttons from the UI and assume users won't attack the endpoint directly. Always enforce authorization in controllers regardless of what the view shows.

Action Policy as an Alternative

ActionPolicy is another mature gem that takes a slightly different approach. It uses method names with underscores instead of question marks and provides built-in support for scoping queries, rule composition, and caching. Teams managing large rule sets often prefer ActionPolicy's structure.

Install and generate a policy:

ruby
# Gemfile
gem 'action_policy', '~> 0.7'

# Terminal
bundle install
rails generate action_policy Post

ActionPolicy policies look similar but with some differences in structure and capabilities:

ruby
class PostPolicy < ApplicationPolicy
  # Methods without ? for authorization checks
  def index?
    true
  end

  def show?
    true
  end

  def create?
    user&.persisted?
  end

  def update?
    user&.persisted? && (record.author_id == user.id || user.admin?)
  end

  def destroy?
    update?  # Reuse logic
  end

  # Scope returns records the user can access
  def index_scope(relation)
    return relation if user&.admin?
    relation.where(author_id: user.id).or(relation.published)
  end
end

ActionPolicy integrates with Rails controllers cleanly and supports rule composition for complex permission hierarchies:

ruby
class PostsController < ApplicationController
  authorize :post, with: PostPolicy

  def index
    @posts = authorized_scope(Post.all)
  end

  def show
    @post = Post.find(params[:id])
    authorize! @post
  end

  def update
    @post = Post.find(params[:id])
    authorize! @post
    # ...
  end
end

ActionPolicy shines when you need complex scoping logic with performance considerations. The authorized_scope method automatically applies the policy's scope, ensuring users only see records they're permitted to access. In e-commerce systems with thousands of orders, this prevents N+1 queries and data leaks:

ruby
class OrderPolicy < ApplicationPolicy
  def index_scope(relation)
    return relation if user.admin?
    return relation.where(company_id: user.company_id) if user.manager?
    relation.where(customer_id: user.id)
  end
end

# Controller
def index
  # Only executes a single query, no N+1
  @orders = authorized_scope(Order.includes(:customer, :line_items))
  @orders = @orders.where(status: params[:status]) if params[:status].present?
end

Checking Authorization in Controllers

Authorization checks belong in the controller before your business logic runs. The pattern is consistent: instantiate or find your resource, authorize the action, then proceed. This layering prevents bugs where services are called without authorization.

For REST actions, use before_action callbacks with policies to reduce repetition:

ruby
class OrdersController < ApplicationController
  include Pundit::Authorization
  before_action :authenticate_user!
  before_action :set_order, only: [:show, :update, :cancel, :refund]
  before_action :authorize_order, only: [:show, :update, :cancel, :refund]

  def show
    @order = @order  # Already authorized
  end

  def update
    if @order.update(order_params)
      redirect_to @order, notice: 'Order updated.'
    else
      render :edit
    end
  end

  def cancel
    @order.update(status: 'cancelled')
    redirect_to @order, notice: 'Order cancelled.'
  end

  def refund
    authorize @order, :refund?
    if @order.process_refund
      redirect_to @order, notice: 'Refund processed.'
    else
      redirect_to @order, alert: 'Refund failed.'
    end
  end

  private

  def set_order
    @order = Order.find(params[:id])
  end

  def authorize_order
    action_name_sym = action_name.to_sym
    authorize @order, "#{action_name}?"
  end
end

For custom actions or non-REST endpoints, authorize explicitly within the action. This is especially important for side-effecting operations:

ruby
def export_csv
  @orders = Order.all
  authorize @orders, :export_csv?
  
  csv_data = Orders::ExportService.call(@orders)
  send_data csv_data, filename: 'orders.csv'
end

def batch_update_status
  orders = Order.where(id: params[:order_ids])
  authorize orders, :batch_update?
  
  orders.update_all(status: params[:status], updated_at: Time.current)
  redirect_to orders_path, notice: 'Orders updated.'
end

Be explicit about what you're checking. Don't silently skip operations when authorization fails—raise the error and let your error handler catch it. This keeps the authorization layer visible in your code and prevents subtle security regressions.

One common edge case: API endpoints that return different HTTP status codes. Use 403 Forbidden for authorization failures, not 404 Not Found:

ruby
class Api::OrdersController < ApplicationController
  rescue_from Pundit::NotAuthorizedError, with: :forbidden_response

  def show
    @order = Order.find(params[:id])
    authorize @order, :show?
    render json: @order
  end

  private

  def forbidden_response(exception)
    render json: { error: 'Forbidden' }, status: :forbidden
  end
end

Scoping Queries to Visible Records

Authorization isn't just about blocking actions; it's about filtering data. A user should never retrieve records they're not permitted to see, even through bulk queries. This is critical in multi-tenant systems where data isolation is your security boundary.

Define a scope in your policy and apply it to all queries:

ruby
class InvoicePolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      if user.admin?
        scope.all
      elsif user.accountant?
        scope.where(company_id: user.company_id)
      else
        scope.where(created_by_id: user.id)
      end
    end
  end

  def show?
    user.admin? || user.company_id == record.company_id
  end

  def update?
    show? && (record.status == 'draft' || user.admin?)
  end
end

Use policy_scope in your controller consistently:

ruby
class InvoicesController < ApplicationController
  include Pundit::Authorization

  def index
    @invoices = policy_scope(Invoice)
    @invoices = @invoices.where(status: params[:status]) if params[:status].present?
    @invoices = @invoices.includes(:line_items).page(params[:page])
  end

  def archived
    @invoices = policy_scope(Invoice).where.not(archived_at: nil)
  end
end

This ensures users only see invoices they have permission to view. If a user tries to access /invoices/999 and invoice 999 belongs to another account, your show? policy will reject it. The defense-in-depth approach: scope at the database level and authorize the specific record.

For complex scoping, compose scopes in your ActiveRecord models to keep them testable:

ruby
class Post < ApplicationRecord
  scope :published, -> { where(status: 'published') }
  scope :authored_by, ->(user) { where(author_id: user.id) }
  scope :in_company, ->(company) { where(company_id: company.id) }
  scope :visible_to, ->(user) do
    if user.admin?
      all
    else
      published.or(authored_by(user))
    end
  end
end

class Comment < ApplicationRecord
  scope :on_visible_posts, ->(user) do
    joins(:post).where(posts: { id: Post.visible_to(user) })
  end
end

Then use it in your policy:

ruby
class PostPolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      scope.visible_to(user)
    end
  end
end

class CommentPolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      scope.on_visible_posts(user)
    end
  end
end

In a ride-hailing or delivery system, scoping becomes critical for performance and privacy:

ruby
class RidePolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      case user.role
      when 'admin'
        scope.all
      when 'driver'
        scope.where(driver_id: user.id).or(scope.where(status: 'assigned'))
      when 'passenger'
        scope.where(passenger_id: user.id)
      else
        scope.none
      end
    end
  end

  def show?
    user.admin? || record.driver_id == user.id || record.passenger_id == user.id
  end

  def cancel?
    show? && record.status.in?(['requested', 'assigned'])
  end
end

Common Mistakes and How to Avoid Them

Authorizing at the view level only: Conditional rendering in views creates a false sense of security. Users can craft URLs or API calls to bypass UI checks. Always authorize in the controller or service layer. View-level authorization should only hide UI elements to improve UX, never as a security mechanism.

Forgetting to scope queries: If you fetch all records from the database and filter them in Ruby, you've already leaked data. Scope queries at the database level so unauthorized records never leave the database. This is especially dangerous in background jobs that process large datasets.

ruby
# WRONG: This fetches all orders into memory
@user_orders = Order.all.select { |order| order.customer_id == current_user.id }

# CORRECT: Database does the filtering
@user_orders = policy_scope(Order).where(customer_id: current_user.id)

Mixing authorization with business logic: Keep policies focused on permissions. If your policy contains domain logic (e.g., "can only update if the order is pending"), move that to a validator or service object. Policies should answer "who can do this," not "should this be done."

ruby
# WRONG: Business logic in policy
class OrderPolicy < ApplicationPolicy
  def ship?
    user.admin? && record.all_items_in_stock? && record.payment_verified?
  end
end

# CORRECT: Authorization in policy, validation in model/service
class OrderPolicy < ApplicationPolicy
  def ship?
    user.admin? && record.can_ship?  # Delegates to model validation
  end
end

class Order < ApplicationRecord
  def can_ship?
    all_items_in_stock? && payment_verified?
  end
end

Not testing policies: Policies are pure Ruby. Unit test them thoroughly with different user roles and edge cases. Don't rely on controller or integration tests alone.

ruby
require 'test_helper'

class PostPolicyTest < ActiveSupport::TestCase
  def setup
    @user = users(:regular)
    @admin = users(:admin)
    @author = users(:author)
    @post = posts(:published)
  end

  def test_regular_user_cannot_destroy
    policy = PostPolicy.new(@user, @post)
    assert_not policy.destroy?
  end

  def test_author_can_update_own_post
    post = posts(:user_post)
    post.update(author_id: @user.id)
    policy = PostPolicy.new(@user, post)
    assert policy.update?
  end

  def test_author_cannot_update_another_post
    post = posts(:another_user_post)
    policy = PostPolicy.new(@user, post)
    assert_not policy.update?
  end

  def test_admin_can_destroy_any_post
    policy = PostPolicy.new(@admin, @post)
    assert policy.destroy?
  end

  def test_anonymous_user_cannot_create
    policy = PostPolicy.new(nil, Post.new)
    assert_not policy.create?
  end

  def test_scope_returns_only_owned_and_published_posts
    user = users(:regular)
    create_list(:post, 3, author: user, status: 'published')
    create_list(:post, 2, author: user, status: 'draft')
    create_list(:post, 5, status: 'published')
    
    policy_scope = Pundit.policy_scope(user, Post)
    assert_equal 8, policy_scope.count
  end
end

Using authorization checks inside a transaction without proper error handling: If an authorization check fails inside a transaction, the entire transaction rolls back. Handle authorization early in your action, before entering database transactions.

Not auditing authorization failures: In production, you need to know when authorization is failing. Users probing for vulnerabilities will trigger these errors repeatedly. Log them with context:

ruby
class ApplicationController < ActionController::Base
  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

  private

  def user_not_authorized(exception)
    SecurityAudit.log(
      user_id: current_user&.id,
      action: "#{controller_name}##{action_name}",
      resource_id: params[:id],
      resource_type: controller_name.singularize,
      timestamp: Time.current,
      ip_address: request.remote_ip
    )
    
    flash[:alert] = "You are not authorized to perform this action."
    redirect_to(request.referrer || root_path)
  end
end

Testing Authorization in Integration Tests

Beyond unit testing policies, test the full authorization flow in controller and integration tests:

ruby
class PostsControllerTest < ActionDispatch::IntegrationTest
  def setup
    @user = users(:regular)
    @other_user = users(:other)
    @admin = users(:admin)
    @post = posts(:user_post)
  end

  def test_unauthorized_user_cannot_destroy_post
    sign_in @other_user
    delete post_path(@post)
    
    assert_response :redirect
    assert_redirected_to root_path
    assert_equal "You are not authorized to perform this action.", flash[:alert]
    assert_not Post.exists?(@post.id).nil?
  end

  def test_author_can_update_own_post
    sign_in @user
    patch post_path(@post), params: { post: { title: 'New Title' } }
    
    assert_response :redirect
    assert @post.reload.title == 'New Title'
  end

  def test_admin_can_destroy_any_post
    sign_in @admin
    delete post_path(@post)
    
    assert_response :redirect
    assert_not Post.exists?(@post.id)
  end

  def test_scope_limits_index_results
    sign_in @user
    other_post = posts(:other_user_post)
    
    get posts_path
    
    assert_response :success
    assert_includes assigns(:posts), @post
    assert_not_includes assigns(:posts), other_post
  end
end

Key Takeaways

  • Use Pundit for straightforward authorization with clear policy classes per model.
  • Choose ActionPolicy if you need sophisticated scoping, rule composition, and reusable authorization logic at scale.
  • Always authorize in the controller before performing the action; never rely on view-level checks alone.
  • Scope database queries using policy scopes so users only retrieve records they're permitted to see, especially in multi-tenant systems.
  • Keep authorization logic separate from business logic; policies should answer "who can do what," not "what should happen."
  • Test policies thoroughly as pure Ruby objects with multiple user roles and edge cases, plus integration tests for the full flow.
  • Be explicit: raise authorization errors and handle them in a central location rather than silently failing.
  • Log authorization failures for security auditing; these errors can indicate attack attempts.
  • Use 403 Forbidden, not 404 Not Found, for authorization failures in APIs to distinguish from missing resources.
  • Remember that authorization is a defense-in-depth approach: scope at the database level and authorize specific records, don't rely on just one layer.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles