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:
# Gemfile
gem 'pundit', '~> 2.3'
# Terminal
bundle install
rails generate pundit:install
rails generate pundit:policy PostYour generated policy file lives at app/policies/post_policy.rb and inherits from ApplicationPolicy:
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
endThe 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:
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
endNotice 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:
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
endWhen authorization fails, Pundit raises Pundit::NotAuthorizedError. Handle this in your ApplicationController with a user-friendly error message and audit trail for security:
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
endUse Pundit in views to conditionally render UI elements, but remember this is a UX enhancement, not a security measure:
<% 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:
# Gemfile
gem 'action_policy', '~> 0.7'
# Terminal
bundle install
rails generate action_policy PostActionPolicy policies look similar but with some differences in structure and capabilities:
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
endActionPolicy integrates with Rails controllers cleanly and supports rule composition for complex permission hierarchies:
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
endActionPolicy 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:
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?
endChecking 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:
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
endFor custom actions or non-REST endpoints, authorize explicitly within the action. This is especially important for side-effecting operations:
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.'
endBe 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:
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
endScoping 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:
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
endUse policy_scope in your controller consistently:
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
endThis 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:
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
endThen use it in your policy:
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
endIn a ride-hailing or delivery system, scoping becomes critical for performance and privacy:
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
endCommon 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.
# 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."
# 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.
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:
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:
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.






Comments
Loading comments…