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?
- A01:2021 — Broken Access Control
- A02:2021 — Cryptographic Failures
- A03:2021 — Injection
- A04:2021 — Insecure Design
- A05:2021 — Security Misconfiguration
- A06:2021 — Vulnerable and Outdated Components
- A07:2021 — Identification and Authentication Failures
- A08:2021 — Software and Data Integrity Failures
- A09:2021 — Security Logging and Monitoring Failures
- 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 userclass InvoicesController < ApplicationController def show @invoice = Invoice.find(params[:id]) endendChange 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 ownsclass InvoicesController < ApplicationController def show @invoice = current_user.invoices.find(params[:id]) endendFor 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 GPUclass User < ApplicationRecord before_save do self.password_digest = Digest::SHA256.hexdigest(password) endend# Fixed — adaptive, salted hashing via bcrypt (Rails ships this by default)class User < ApplicationRecord has_secure_passwordendhas_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 directlyUser.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 escapeUser.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 redeemedclass CouponRedemptionsController < ApplicationController def create current_user.orders.find(params[:order_id]) .apply_discount!(params[:coupon_code]) endendNothing 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 clientclass 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 endendA 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.rbRails.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 HTTPend# FixedRails.application.configure do config.consider_all_requests_local = false config.force_ssl = trueendconsider_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 CVEnokogiri (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.
bundle outdatedbundle audit check --updatebundle update nokogiriWire 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 throttlingclass 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 endendA perfectly secure password check, run with no limit on how many times an attacker can try it, isn’t actually secure in practice.
# Fixed — rack-attack throttles login attempts by IP and by targeted accountclass 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? endendIf 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 objectsclass SessionsController < ApplicationController def restore @preferences = Marshal.load(Base64.decode64(params[:state])) endendMarshal.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 objectsclass SessionsController < ApplicationController def restore @preferences = JSON.parse(Base64.decode64(params[:state])) endendThe 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 happenedclass ApplicationController < ActionController::Base rescue_from Pundit::NotAuthorizedError do |exception| head :forbidden endendA 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 laterclass 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 endendMake 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 validationclass WebhooksController < ApplicationController def preview response = Net::HTTP.get(URI(params[:url])) render plain: response endendPoint 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 fetchingrequire "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) endendOWASP 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.