Skip to content
RY

OWASP Top 10 (2021): The Ten Risks Every Rails Developer Should Know

Ram Laxman Yadav14 min read

Every few years, a list of ten category names gets passed around every engineering Slack channel, half-read, and forgotten until the next security audit. That’s a shame, because the OWASP Top 10 isn’t abstract risk-management theater — every category maps to a line of Rails code you’ve probably written at some point without thinking twice about it. This post walks through all ten, with a real vulnerable snippet and its fix for each one.

1. What is OWASP?

OWASP — the Open Web Application Security Project — is a non-profit foundation that produces free, vendor-neutral application security resources: testing guides, cheat sheets, tools, and the one most people have actually heard of, the OWASP Top 10. The Top 10 is a ranked list of the most critical web application security risks, built from a mix of real-world breach and vulnerability data contributed by security vendors and consultancies, plus a survey of practicing AppSec professionals about what they see as the biggest emerging risks. It isn’t one person’s opinion — it’s closer to a consensus snapshot of the industry.

Note

This post covers the OWASP Top 10:2021 edition — the exact ten categories most security training, compliance checklists, and tools (including this blog’s own Scryer) are still built around as of this writing. OWASP released an updated Top 10:2025 edition in late 2025 that reshuffles the ranking, folds Server-Side Request Forgery into Broken Access Control, and adds two new categories (Software Supply Chain Failures, Mishandling of Exceptional Conditions). The 2021 list below remains the right starting point — the underlying risks haven’t gone anywhere — but if you’re citing a ranking in a compliance document, check which edition it expects.

2. What is the necessity of it?

Nobody ships a web application planning to have a SQL injection bug. These issues show up because “get the feature out” and “think like an attacker” are rarely the same mental mode, and most teams don’t have a dedicated security engineer reviewing every pull request. The OWASP Top 10 exists to close that gap cheaply:

  • A shared vocabulary. “We have a broken access control issue in the invoices controller” means the same thing to every engineer who’s seen this list, instead of a vague “security bug” ticket nobody prioritizes.
  • A prioritization signal. Ten categories, ranked by real prevalence and impact data, tell you where limited security review time actually pays off — rather than guessing.
  • A training and hiring baseline. It’s become the de facto checklist referenced in security certifications, compliance frameworks (PCI-DSS explicitly references it), and nearly every application security tool’s rule set — including static analyzers that scan Ruby and Rails code specifically.
  • A way to catch what code review misses. Most of these categories are easy to introduce by accident and easy to miss in a quick review, because the code “looks fine” — it runs, it returns the right data for the happy path, and nothing about it looks obviously wrong unless you’re specifically looking for this class of bug.

3. What are the Top 10 issues?

  1. A01:2021 — Broken Access Control
  2. A02:2021 — Cryptographic Failures
  3. A03:2021 — Injection
  4. A04:2021 — Insecure Design
  5. A05:2021 — Security Misconfiguration
  6. A06:2021 — Vulnerable and Outdated Components
  7. A07:2021 — Identification and Authentication Failures
  8. A08:2021 — Software and Data Integrity Failures
  9. A09:2021 — Security Logging and Monitoring Failures
  10. A10:2021 — Server-Side Request Forgery (SSRF)

4. Each issue, in detail, with a Rails example

A01 — Broken Access Control

The most common finding in the entire list — OWASP’s own data puts it in roughly 94% of tested applications. The pattern is always the same: the application correctly identifies who the user is, but fails to check whether that user is actually allowed to touch the specific record they’re asking for.

# Vulnerable — trusts the ID in the URL belongs to the logged-in user
class InvoicesController < ApplicationController
def show
@invoice = Invoice.find(params[:id])
end
end

Change id in the URL from your own invoice to someone else’s, and you’ll see their invoice. This is a textbook Insecure Direct Object Reference (IDOR).

# Fixed — scope the lookup to records the current user actually owns
class InvoicesController < ApplicationController
def show
@invoice = current_user.invoices.find(params[:id])
end
end

For anything beyond simple ownership, a real authorization library — Pundit or CanCanCan — centralizes the “who can do what” rules instead of leaving them scattered across controllers where one missing check is easy to overlook.

A02 — Cryptographic Failures

This is what used to be called “Sensitive Data Exposure” — the root cause moved from the symptom (data got exposed) to the actual failure (the cryptography protecting it was weak, missing, or home-grown).

# Vulnerable — fast, unsalted hash; crackable with a rainbow table or a GPU
class User < ApplicationRecord
before_save do
self.password_digest = Digest::SHA256.hexdigest(password)
end
end
# Fixed — adaptive, salted hashing via bcrypt (Rails ships this by default)
class User < ApplicationRecord
has_secure_password
end

has_secure_password uses bcrypt, which is salted automatically and deliberately slow — and that slowness is tunable upward as hardware gets faster, which a fast general-purpose hash can never give you back. For data at rest beyond passwords (SSNs, API tokens), Rails 7’s built-in Active Record Encryption (encrypts :ssn) covers the “encrypt sensitive columns” half of this category; config.force_ssl = true covers the “encrypt it in transit too” half.

A03 — Injection

SQL injection is the classic example, but this category also absorbed cross-site scripting (XSS) in the 2021 revision — both are the same root mistake: untrusted input gets interpreted as code or markup instead of treated as inert data.

# Vulnerable — string interpolation builds the query directly
User.where("email = '#{params[:email]}'")

A params[:email] value of ' OR '1'='1 turns that into a query that returns every user. ActiveRecord’s own query interface parameterizes for you automatically:

# Fixed — ActiveRecord parameterizes this; there's no string to escape
User.where(email: params[:email])

The XSS half shows up differently — Rails auto-escapes everything rendered through <%= %> in ERB, which is why most Rails apps don’t have an XSS problem by default. The risk reappears the moment someone opts back out of that protection:

<%# Vulnerable — explicitly disables the auto-escaping %>
<%= raw @comment.body %>
<%== @comment.body %>

If you need to render user-supplied rich text, sanitize it through an allow-list first (sanitize(@comment.body, tags: %w[b i em strong])) rather than marking the whole string safe.

A04 — Insecure Design

This one is different from the rest — it’s not a coding mistake, it’s a missing control that was never designed in at all. A business rule that assumes users will behave reasonably, with no enforcement if they don’t.

# Vulnerable — no limit on how many times a coupon can be redeemed
class CouponRedemptionsController < ApplicationController
def create
current_user.orders.find(params[:order_id])
.apply_discount!(params[:coupon_code])
end
end

Nothing here is a syntax-level bug — it runs exactly as written. But if apply_discount! doesn’t itself enforce a one-redemption-per-user rule, nothing stops someone from scripting a few hundred requests against this endpoint and discounting every order they can find.

# Fixed — enforce the business rule at the data layer, not just trust the client
class Order < ApplicationRecord
has_one :coupon_redemption
def apply_discount!(code)
raise AlreadyRedeemedError if coupon_redemption.present?
transaction do
create_coupon_redemption!(code: code)
update!(total: total - Coupon.find_by!(code: code).amount)
end
end
end

A unique database constraint on coupon_redemptions.order_id backs this up so the rule holds even under concurrent requests. The real fix for this category is procedural, not just code: threat-model the abuse cases for a flow (“what happens if someone calls this 10,000 times?”) before you build it, not after someone does.

A05 — Security Misconfiguration

The gap between “secure by default” and “secure as actually deployed.” Rails’ own defaults are reasonably good; this category is almost always a production config that quietly drifted or was never set correctly in the first place.

# Vulnerable — config/environments/production.rb
Rails.application.configure do
config.consider_all_requests_local = true # leaks stack traces + env vars to users
config.force_ssl = false # traffic can downgrade to plain HTTP
end
# Fixed
Rails.application.configure do
config.consider_all_requests_local = false
config.force_ssl = true
end

consider_all_requests_local = true in production means any unhandled exception renders the full Rails debug page — stack trace, local variables, and often environment variables — straight to whoever triggered it. Add security headers while you’re in there (config.action_dispatch.default_headers for X-Frame-Options, Content-Security-Policy, and friends, or the secure_headers gem for more control), and make sure config/master.key / credentials.yml.enc never ends up committed unencrypted.

A06 — Vulnerable and Outdated Components

Your application code can be flawless and still ship a critical vulnerability — one that lives in a gem you depend on, that you haven’t updated since you added it.

# Gemfile.lock — pinned to a version with a known, published CVE
nokogiri (1.13.6)

The fix isn’t a code change, it’s a process one: know what you depend on, and know when one of those dependencies gets a security advisory.

Terminal window
bundle outdated
bundle audit check --update
bundle update nokogiri

Wire bundle-audit (or a tool that covers more ground — Scryer scores dependency risk alongside security, performance, and style findings in one pass) into CI so a newly disclosed CVE in something you already depend on fails the build instead of sitting unnoticed in Gemfile.lock for the next eighteen months.

A07 — Identification and Authentication Failures

Weak password policy, missing rate limiting, and session handling mistakes all live here. The most common real-world version is credential stuffing: an attacker takes a list of leaked email/password pairs from an unrelated breach and tries them against your login form, because people reuse passwords.

# Vulnerable — unlimited login attempts, no lockout, no throttling
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
session[:user_id] = user.id
redirect_to root_path
else
render :new, status: :unprocessable_entity
end
end
end

A perfectly secure password check, run with no limit on how many times an attacker can try it, isn’t actually secure in practice.

config/initializers/rack_attack.rb
# Fixed — rack-attack throttles login attempts by IP and by targeted account
class Rack::Attack
throttle("logins/ip", limit: 5, period: 20.seconds) do |req|
req.ip if req.path == "/login" && req.post?
end
throttle("logins/email", limit: 5, period: 20.seconds) do |req|
req.params["email"].presence if req.path == "/login" && req.post?
end
end

If you’re on Devise, its :lockable module gives you account lockout after N failed attempts for free; pair either approach with multi-factor authentication wherever the account is high-value enough to justify the friction.

A08 — Software and Data Integrity Failures

This category covers trusting something — code, a dependency, serialized data — without verifying it actually is what it claims to be. Insecure deserialization, previously its own category, now lives here.

# Vulnerable — deserializing untrusted input can instantiate arbitrary objects
class SessionsController < ApplicationController
def restore
@preferences = Marshal.load(Base64.decode64(params[:state]))
end
end

Marshal.load (and YAML.unsafe_load/plain YAML.load on older Psych versions) will happily reconstruct arbitrary Ruby objects from the input — including ones whose initializers or callbacks do something you never intended, which is how deserialization bugs turn into remote code execution.

# Fixed — restrict to plain data, never arbitrary objects
class SessionsController < ApplicationController
def restore
@preferences = JSON.parse(Base64.decode64(params[:state]))
end
end

The supply-chain half of this category is the same principle applied to your dependencies rather than a request parameter: pin gem versions, verify they come from the official RubyGems source rather than an arbitrary git URL, and treat your CI/CD pipeline’s write access as something to lock down — the SolarWinds and ua-parser-js incidents both started with a trusted build or publish pipeline getting compromised, not a line of vulnerable application code.

A09 — Security Logging and Monitoring Failures

The quietest category on this list, because by definition its failures don’t show up until something goes wrong and there’s nothing to look back at. A real documented case: a healthcare data breach went undetected for roughly a decade, because nobody was logging or monitoring the system closely enough to notice.

# Vulnerable — swallows the failure with no trace it ever happened
class ApplicationController < ActionController::Base
rescue_from Pundit::NotAuthorizedError do |exception|
head :forbidden
end
end

A user repeatedly hitting endpoints they’re not authorized for is either a confused user or someone probing your access controls — and with this code, you’ll never know which, because nothing records that it happened.

# Fixed — log it with enough context to actually investigate later
class ApplicationController < ActionController::Base
rescue_from Pundit::NotAuthorizedError do |exception|
Rails.logger.warn(
"[authz] user=#{current_user&.id.inspect} " \
"denied #{exception.policy.class}##{exception.query} " \
"on #{exception.record.class}/#{exception.record.try(:id)}"
)
head :forbidden
end
end

Make sure config.filter_parameters in config/initializers/filter_parameter_logging.rb redacts passwords, tokens, and other secrets from those same logs — you want enough detail to investigate an incident, not a log file that becomes its own data breach. Ship logs somewhere that survives the server being compromised (a centralized log aggregator, not just local disk), and have an actual incident response plan written down before you need one.

A10 — Server-Side Request Forgery (SSRF)

SSRF happens when your server fetches a URL on the user’s behalf without validating where that URL actually points — turning your server into a proxy an attacker can aim at infrastructure it was never supposed to reach.

# Vulnerable — fetches whatever URL the user supplies, no validation
class WebhooksController < ApplicationController
def preview
response = Net::HTTP.get(URI(params[:url]))
render plain: response
end
end

Point params[:url] at http://169.254.169.254/latest/meta-data/iam/security-credentials/ — the AWS instance metadata endpoint — and, if this runs inside AWS, you may have just handed an attacker your server’s IAM credentials. The endpoint doesn’t need a firewall hole; your own server fetched it willingly.

# Fixed — resolve the host, reject private/link-local ranges, before fetching
require "resolv"
class WebhooksController < ApplicationController
BLOCKED_RANGES = [
IPAddr.new("169.254.0.0/16"), # link-local / cloud metadata
IPAddr.new("127.0.0.0/8"), # loopback
IPAddr.new("10.0.0.0/8"), # private
IPAddr.new("172.16.0.0/12"), # private
IPAddr.new("192.168.0.0/16"), # private
].freeze
def preview
uri = URI(params[:url])
raise InvalidURL unless %w[http https].include?(uri.scheme)
resolved_ip = IPAddr.new(Resolv.getaddress(uri.host))
raise InvalidURL if BLOCKED_RANGES.any? { |range| range.include?(resolved_ip) }
render plain: Net::HTTP.get(uri)
end
end

OWASP is explicit on this one: don’t try to defend with a deny-list or a regex on the URL string — attackers have entire toolkits of encoding tricks (redirects, DNS rebinding, alternate IP notations) built specifically to slip past exactly that kind of check. Resolve the actual destination IP and validate against a positive allow-list of what’s acceptable.

5. Best practices to avoid these

Across all ten categories, the same handful of habits do most of the actual work:

  • Default to deny, not allow. Access control, URL fetching, deserialization — in every case, the secure version of the code explicitly checks and rejects, rather than processing everything except what it specifically thought to block.
  • Let frameworks and libraries do the dangerous parts. ActiveRecord’s query interface, has_secure_password, Rails’ ERB auto-escaping — these exist because the safe version of these operations is easy to get wrong by hand and hard to get wrong through the abstraction. Reach for the raw, manual version only when you have a specific reason to.
  • Put dependency and secret scanning in CI, not in someone’s memory. bundle-audit, a SAST tool, and secret scanning catch what a code reviewer skimming a diff won’t reliably catch every time.
  • Log security-relevant events with context, and ship those logs somewhere that survives a compromise. You can’t investigate — or even detect — what you didn’t record.
  • Threat-model the business logic, not just the code. Insecure Design findings don’t show up in a linter. They show up when someone asks “what happens if a legitimate user does this ten thousand times in a row?”
  • Treat every piece of user input as hostile until proven otherwise — the URL in an SSRF-prone fetch, the serialized blob in a session, the ID in a route param. Every category on this list is some variation of that same input trust being misplaced.

6. Conclusion

None of these ten categories require exotic attack knowledge to fix — every example above is a handful of lines, not a rewrite. What they actually require is treating application security as a standing engineering practice rather than a pre-launch checklist: dependency scanning running continuously, authorization checks that default to deny, and logging that assumes something will eventually go wrong. The OWASP Top 10 isn’t a list of edge cases. It’s a list of the mistakes that are easiest to make precisely because the vulnerable code looks completely ordinary until you know what to look for.