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 Unauthorized response 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_password backed by bcrypt.

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 Forbidden response 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

GET /loans

Loan Index

Ana (Staff)

200 OK

Retrieves and displays all active loans across all students.

GET /loans

Loan Index

Pedro (Student)

200 OK

Retrieves a scoped dataset containing only Pedro's personal loans.

GET /loans/5

Show Pedro's Loan

Ana (Staff)

200 OK

Access granted; administrative review.

GET /loans/5

Show Pedro's Loan

Pedro (Owner)

200 OK

Access granted; record matches the authenticated student.

GET /loans/8

Show Other User's Loan

Ana (Staff)

200 OK

Access granted; administrative privileges apply.

GET /loans/8

Show Other User's Loan

Pedro (Non-owner)

403 Forbidden

Access denied; record does not belong to the student.

GET /loans/5/edit

Edit Form View

Ana (Staff)

200 OK

Renders form with administrative inputs.

GET /loans/5/edit

Edit Form View

Pedro (Student)

403 Forbidden

Access denied; students cannot modify loan attributes.

PATCH /loans/5

Update (state: "returned")

Ana (Staff)

302 Found

State update executes; redirects to the updated resource.

PATCH /loans/5

Update (state: "returned")

Pedro (Student)

403 Forbidden

Update rejected; database state remains unaltered.

GET /loans/new

Create Form View

Ana (Staff)

200 OK

Form includes fields to assign the loan to arbitrary students.

GET /loans/new

Create Form View

Pedro (Student)

200 OK

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:

  1. DRY Principle Violation: Across large applications (e.g., multiple controllers handling standard RESTful actions), authorization rules are duplicated repeatedly across edit, update, show, and destroy.
  2. 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.
  3. 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" or admin=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
mail
email
]

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.