Web Application Security, Authentication, and Authorization in Ruby on Rails
In modern web application engineering, controlling access to data and system capabilities is a critical foundational requirement. Building a secure web application involves establishing multiple distinct layers of defense: transport encryption, credential protection, parameter sanitization, static analysis, and a strict architectural separation between Authentication and Authorization. Failing to separate these concerns cleanly leads to structural vulnerabilities, privilege escalation, and unmaintainable codebases.
1. Authentication vs. Authorization
Although often conflated, authentication and authorization answer two fundamentally different questions during the lifecycle of an HTTP request.
+-----------------------------------+
| Incoming Request |
+-----------------------------------+
|
v
+-----------------------------------+
| AUTHENTICATION |
| "Who is making |
| this request?" |
+-----------------------------------+
/ \
Valid / \ Invalid / Missing
v v
+-----------------------+ +----------------------------+
| Session Cookie / User | | HTTP 401 Unauthorized |
| Identified | | or Redirect to Login |
+-----------------------+ +----------------------------+
|
v
+-----------------------+
| AUTHORIZATION |
| "Can this user |
| perform this action |
| on this resource?" |
+-----------------------+
/ \
Allowed/ \ Denied
v v
+------------------+ +----------------------------+
| Execute Action & | | HTTP 403 Forbidden |
| Render Response | +----------------------------+
+------------------+
Authentication ("Who are you?")
- Purpose: Establishes and verifies the identity of the person or service initiating the request.
- Mechanism: Evaluates user credentials (such as username, email, and password), session cookies, multi-factor authentication (MFA), or single sign-on (SSO) tokens.
- Failure Handling: Returns an
HTTP 401 Unauthorizedresponse status or issues a redirect sending the user to a login interface. - Rails Ecosystem Tools:
- Rails Native Authentication: Introduced in Rails 8+, providing generated authentication scaffolding (
bin/rails generate authentication) for sessions, passwords, and cookies. - Devise: A long-standing, widely adopted authentication gem handling user registration, recovery, and sessions.
- Custom Implementations: Manual session management using Rails'
has_secure_passwordbacked bybcrypt.
- Rails Native Authentication: Introduced in Rails 8+, providing generated authentication scaffolding (
Authorization ("Are you allowed to do this?")
- Purpose: Determines whether an authenticated user holds the required permissions to execute a specific action on a specific resource or database entity.
- Mechanism: Validates domain business rules against the user's role, record ownership, and the specific controller action.
- Failure Handling: Returns an
HTTP 403 Forbiddenresponse status, signifying that the identity is verified but lacks permission to perform the requested operation. - Rails Ecosystem Tools:
- Pundit: A lightweight, object-oriented library that encapsulates permissions inside plain Ruby policy classes.
- CanCanCan / CanCan: A rule-based authorization library managing permissions centrally within an ability definition.
- Custom Logic: Handwritten conditional checks placed inside models, services, or controllers.
2. Access Control Vulnerabilities: The Authorization Problem
To understand the necessity of authorization, consider a university loan management application ("Loandesk") accessed by two distinct roles:
- Staff Member (Ana): Possesses administrative permissions to inspect, modify, and manage loans across all students.
- Student (Pedro): Possesses restricted permissions limited strictly to his own loan records.
Once authenticated, the application recognizes Pedro's identity. However, without an authorization layer, an application remains vulnerable to Broken Access Control and Insecure Direct Object References (IDOR), where a user manipulates identifiers (such as database IDs in the URL) to access or mutate records belonging to other users.
HTTP Request | Action / Target | Context | Expected Status | Behavioral Outcome |
|---|---|---|---|---|
| Loan Index | Ana (Staff) |
| Retrieves and displays all active loans across all students. |
| Loan Index | Pedro (Student) |
| Retrieves a scoped dataset containing only Pedro's personal loans. |
| Show Pedro's Loan | Ana (Staff) |
| Access granted; administrative review. |
| Show Pedro's Loan | Pedro (Owner) |
| Access granted; record matches the authenticated student. |
| Show Other User's Loan | Ana (Staff) |
| Access granted; administrative privileges apply. |
| Show Other User's Loan | Pedro (Non-owner) |
| Access denied; record does not belong to the student. |
| Edit Form View | Ana (Staff) |
| Renders form with administrative inputs. |
| Edit Form View | Pedro (Student) |
| Access denied; students cannot modify loan attributes. |
| Update ( | Ana (Staff) |
| State update executes; redirects to the updated resource. |
| Update ( | Pedro (Student) |
| Update rejected; database state remains unaltered. |
| Create Form View | Ana (Staff) |
| Form includes fields to assign the loan to arbitrary students. |
| Create Form View | Pedro (Student) |
| Form automatically scopes loan creation to Pedro's profile. |
3. Naive Authorization vs. Policy Objects
The Code Smell of Controller-Level Logic
A naive approach places conditional authorization checks directly within controller actions:
# app/controllers/loans_controller.rb
class LoansController < ApplicationController
before_action :set_loan, only: [:edit, :update]
def edit
unless current_user.staff? && [email protected]?
redirect_to loans_path, alert: "Not allowed" and return
end
end
def update
unless current_user.staff? && [email protected]?
redirect_to loans_path, alert: "Not allowed" and return
end
if @loan.update(loan_params)
redirect_to @loan, notice: "Loan updated successfully."
else
render :edit, status: :unprocessable_entity
end
end
private
def set_loan
@loan = Loan.find(params[:id])
end
def loan_params
params.require(:loan).permit(:state)
end
end
This naive implementation introduces major architectural defects:
- DRY Principle Violation: Across large applications (e.g., multiple controllers handling standard RESTful actions), authorization rules are duplicated repeatedly across
edit,update,show, anddestroy. - Logic Divergence and Security Gaps: If business rules evolve (e.g., allowing teaching assistants to edit active loans), developers must manually find and update every duplicated conditional. Missing an instance creates privilege escalation vulnerabilities.
- Controller Bloat: Controllers become polluted with domain business rules rather than focusing on request/response orchestration.
4. Policy-Based Authorization with Pundit
Pundit resolves these issues by extracting access control rules into plain Ruby classes called Policies, enforcing the Principle of Least Privilege (restricting all access by default and granting explicit permissions atomically).
Base Structure and Installation
To install Pundit and initialize its structure:
bundle add pundit
rails generate pundit:install
This generates app/policies/application_policy.rb, which acts as a secure-by-default base class:
# app/policies/application_policy.rb
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def index?
false
end
def show?
false
end
def create?
false
end
def new?
create?
end
def update?
false
end
def edit?
update?
end
def destroy?
false
end
end
Implementing Resource Policies
Domain policies inherit from ApplicationPolicy and selectively override action predicates:
# app/policies/loan_policy.rb
class LoanPolicy < ApplicationPolicy
def show?
user.staff? || record.student == user.student
end
def update?
user.staff?
end
def edit?
update? && !record.returned?
end
def destroy?
user.staff?
end
def create?
true
end
end
Controller Integration and Exception Handling
Controllers include Pundit::Authorization. Invoking authorize @record checks the corresponding policy method. If it evaluates to false, Pundit raises a Pundit::NotAuthorizedError.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Pundit::Authorization
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
# Ensure authorization is not forgotten during development
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index
private
def user_not_authorized
flash[:alert] = "You are not authorized to perform this action."
render "shared/forbidden", status: :forbidden
# Alternatively: redirect_back(fallback_location: root_path)
end
def pundit_user
current_user
end
end
# app/controllers/loans_controller.rb
class LoansController < ApplicationController
before_action :set_loan, only: [:show, :edit, :update, :destroy]
def show
authorize @loan
end
def new
@loan = authorize Loan.new
end
def update
authorize @loan
if @loan.update(loan_params)
redirect_to @loan
else
render :edit, status: :unprocessable_entity
end
end
private
def set_loan
@loan = Loan.find(params[:id])
end
def loan_params
base = params.require(:loan).permit(:unit, :due_on, :note)
current_user.staff? ? base.merge(params.require(:loan).permit(:state)) : base
end
end
Collection Authorization via Policy Scopes
Validating records individually via authorize @record does not scale to index actions. Fetching all records from the database and filtering them in memory causes data leaks and performance degradation. Pundit resolves this using nested Scope classes to enforce authorization at the SQL query level:
# app/policies/loan_policy.rb
class LoanPolicy < ApplicationPolicy
class Scope < ApplicationPolicy::Scope
def resolve
if user.staff?
scope.all
else
scope.where(student: user.student)
end
end
end
end
In the controller, invoke the scope using policy_scope:
# app/controllers/loans_controller.rb
class LoansController < ApplicationController
def index
@loans = policy_scope(Loan).includes(:student, :unit)
end
end
5. Multi-Layered Defense: Views, Controllers, and APIs
Relying solely on hiding UI elements creates security through obscurity. Server-side views and API layers must work in tandem with backend controllers.
+--------------------------------------------------------------------+
| Client Layer |
| |
| [ View: Hide UI Elements ] ---> Prevents accidental discovery |
| | |
+---------------|----------------------------------------------------+
| Direct HTTP Request (Forced Browsing)
v
+--------------------------------------------------------------------+
| Server Layer |
| |
| [ Controller: authorize ] ---> Enforces policy, rejects unauthorized|
| | requests with HTTP 403 Forbidden |
| v |
| [ Business Logic / Database ] |
+--------------------------------------------------------------------+
View-Level Authorization Checks
Server-side template engines (such as Rails ERB) evaluate policy conditions on the server before transmitting raw HTML to the browser:
<%# app/views/loans/index.html.erb %>
<% @loans.each do |loan| %>
<div>
<span><%= loan.unit.asset_tag %></span>
<span><%= loan.state %></span>
<% if policy(loan).update? %>
<%= link_to "Edit", edit_loan_path(loan) %>
<% end %>
<% if policy(loan).destroy? %>
<%= button_to "Delete", loan_path(loan), method: :delete %>
<% end %>
</div>
<% end %>
Because evaluation occurs during server-side rendering, unauthorized DOM elements and links are completely omitted from the HTML payload. Inspecting the page source via browser developer tools will not reveal unauthorized controls.
Decoupled Architectures (SPAs and APIs)
In architectures where a Single-Page Application (e.g., React) consumes a Rails backend API:
- Frontend: Evaluates user roles to provide immediate interface feedback and route guarding.
- Backend API: Acts as the authoritative security boundary, validating permissions on every incoming JSON/HTTP request. Even if client-side validation is bypassed, the API must reject unauthorized operations with
403 Forbidden.
6. The OWASP Top 10 and Rails Mitigations
The Open Worldwide Application Security Project (OWASP) Top 10 highlights the most critical web application security risks. Rails provides built-in defenses for many of these vectors when configured and used properly.
1. Cross-Site Request Forgery (CSRF)
- Risk: A malicious third-party site tricks an authenticated user's browser into submitting unauthorized requests to the target application by leveraging automatically included session cookies.
- Rails Mitigation: Rails automatically generates and validates a cryptographic, per-form
authenticity_token. Non-GET/POST/PUT/PATCH/DELETE requests lacking this token are rejected.
<%= form_with(model: @loan, local: true) do |f| %>
<%= f.text_field :unit %>
<%= f.submit %>
<% end %>
2. SQL Injection
- Risk: Malicious SQL fragments are concatenated into database queries via unsanitized user inputs, altering query logic or executing destructive commands (e.g.,
'; DROP TABLE users; --). - Rails Mitigation: Active Record uses parameterized queries by default, treating user inputs strictly as query parameters rather than executable SQL.
# Vulnerable: Raw string interpolation
User.where("email = '#{params[:email]}'")
# Secure: Parameterized query placeholders
User.where("email = ?", params[:email])
3. Cross-Site Scripting (XSS)
- Risk: Attackers inject malicious JavaScript into stored application data. When rendered by other users, the script executes inside their browsers, exfiltrating session tokens or performing actions on their behalf.
- Rails Mitigation: ERB templates automatically HTML-escape dynamic outputs. Rails Action Text integrates Trix and HTML sanitizers to strip malicious scripts and unsafe tags from rich text input. Avoid bypassing escaping with
raw()on untrusted inputs.
<%= @comment.body %> <!-- Secure: Escaped by default -->
<%= raw(@comment.body) %> <!-- Dangerous: Renders raw unescaped HTML -->
4. Mass Assignment Vulnerabilities
- Risk: Attackers modify incoming form payloads via developer tools, adding unexpected parameters (e.g.,
loan_state="returned"oradmin=true) to manipulate protected attributes. - Rails Mitigation: Strong Parameters require controllers to define an explicit whitelist of permitted attributes.
def loan_params
params.require(:loan).permit(:unit, :due_on, :note)
end
5. Security Misconfiguration and Leaked Secrets
- Risk: Storing hardcoded database passwords, API credentials, or cloud access keys in source code leads to automated credential harvesting and infrastructure compromise.
- Rails Mitigation: Store secrets using Rails Encrypted Credentials (
config/credentials.yml.enc) or deployment environment variables.
bin/rails credentials:edit
Credentials are encrypted with a key located in config/master.key. The master key is excluded from Git via .gitignore and provided at runtime in production.
Rails.application.credentials.dig(:aws, :access_key_id)
# config/database.yml using environment variables
production:
adapter: postgresql
url: <%= ENV["DATABASE_URL"] %>
6. Software Supply Chain Vulnerabilities
- Risk: Third-party gems or NPM packages may introduce security vulnerabilities, unpatched CVEs, or backdoors.
- Rails Mitigation: Regularly run dependency audits and inspect library health (update frequency, maintainer reputation, and security advisories):
bundle audit
7. Transport Security, Secure Logging, and Static Analysis
Encryption in Transit (HTTPS)
All web application traffic—including login forms, API calls, and public pages—must be transmitted over HTTPS using SSL/TLS. HTTPS guarantees:
- Confidentiality: Protects credentials and payloads from network eavesdropping.
- Integrity: Prevents man-in-the-middle tampering of data in transit.
- Authentication: Validates server identity via signed certificates.
Passwords must additionally be hashed at rest using salted hashing algorithms such as bcrypt via Rails' has_secure_password:
class User < ApplicationRecord
has_secure_password
end
Secure Parameter Filtering in Logs
Application logs must never record plain-text credentials, tokens, or sensitive personal data. Rails provides parameter filtering to sanitize logs:
# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += %w[
passw
password
password_confirmation
secret
token
key
api_key
ssn
otp
certificate
salt
script
]
Rails matches parameter keys against these substrings and replaces their logged values with [FILTERED].
Automated Security Analysis with Brakeman
Brakeman is a static analysis security scanner designed specifically for Ruby on Rails applications. It inspects source code without running the application to detect security flaws such as SQL injections, file access vulnerabilities, insecure configurations, and missing CSRF protections.
bundle exec brakeman
Integrating Brakeman into Continuous Integration (CI) pipelines ensures builds fail if new security vulnerabilities are introduced into the codebase.