Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix missing authentication in Ruby on Rails

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Keep the before_action :authenticate in ApplicationController so every action needs it, and skip_before_action only on the public health route. Verify a server-issued bearer token with OpenSSL::HMAC and ActiveSupport::SecurityUtils.secure_compare, take the user from the verified token, and render 401 when the credential is missing or invalid.

Stack
  • Language Ruby
  • Runtime Ruby 4.0.7
  • Framework Ruby on Rails 8.1.4 (API controller, before_action, OpenSSL::HMAC, ActiveSupport::SecurityUtils)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 26 KB
On this page

Steps

  1. Put authentication in a before_action in ApplicationController, so every controller that inherits from it is protected. Opt a public route out explicitly with skip_before_action.
  2. Verify a credential, do not merely read a claimed identity. Recompute the token's HMAC over its payload and compare with ActiveSupport::SecurityUtils.secure_compare (constant time).
  3. Take the user's identity from the verified token and load the record (User.find_by(id:)). Never read the acting user from request.headers['X-User-Id'], params or the body.
  4. Render 401 when there is no credential or the signature does not match, and keep current_user available to actions through an attr_reader.
  5. Test through ActionDispatch::IntegrationTest: a legitimately signed token works, a forged X-User-Id header is ignored, and an unsigned token is refused, with nothing changed.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerable…/authentication/authentication/vulnerable.rbRuby
def self.authenticate(request)
  # The client simply says who they are. We believe them.
  id = request.headers["X-User-Id"]
  id.present? ? User.find_by(id:) : nil
end

With IMPL=vulnerable, 2 of 9 tests fail (2 exploit tests).

The fix

Fixedapp/authentication/authentication/fixed.rbRuby
def self.authenticate(request)
  token = Authentication.bearer_token(request)
  return nil unless token

  username, signature = token.split(".", 2)
  return nil if username.blank? || signature.blank?

  secret = Rails.application.config.x.token_secret
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, username)
  # secure_compare is constant time, so a token the server never signed is refused and the
  # username cannot be forged or swapped.
  return nil unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)

  # The identity is whatever the verified token says, never the X-User-Id header.
  User.find_by(id: username)
end

With IMPL=fixed, 9 of 9 tests pass.

A fix that looks safe but is not

Wrong fixapp/authentication/authentication/wrong.rbRuby
def self.authenticate(request)
  token = Authentication.bearer_token(request)
  return nil unless token

  # Decode the claimed username. There is no signature to check, so any token is accepted.
  id = token.unpack1("m0")
  id.present? ? User.find_by(id:) : nil
end

With IMPL=wrong, 2 of 9 tests fail (2 exploit tests).

The authentication before_action

app/controllers/application_controller.rbRuby
# Authentication runs on every action here (healthz opts out with skip_before_action). The
# authenticator the IMPL names verifies the credential and returns the user; no credential, or an
# unverifiable one, is 401, so a protected action never runs for an anonymous or forged caller.
def authenticate
  @current_user = Authentication.current.authenticate(request)
  render json: { error: "sign in first" }, status: :unauthorized unless @current_user
end

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/account_test.rbRuby
# Exploit: each payload claims an identity the caller cannot prove. The secure outcome is 401
# with nothing changed; the forged identity must never be honoured.

test "exploit: a forged X-User-Id header is not trusted" do
  get "/account", headers: { "X-User-Id" => "bob" } # "I am bob" with no credential
  assert_equal 401, response.status, "an X-User-Id header is not a credential: #{response.body}"
end

test "exploit: an unsigned bearer token is rejected" do
  # A token the attacker made: base64 of the victim's name, with no signature.
  get "/account", headers: { "Authorization" => "Bearer #{['bob'].pack('m0')}" }
  assert_equal 401, response.status, "an unverified token is not a credential: #{response.body}"
end

test "exploit: email takeover via a forged header" do
  before = email_of("bob")
  post "/account/email", params: { email: "attacker@evil.example" }, headers: { "X-User-Id" => "bob" }, as: :json
  assert_equal before, email_of("bob"), "critical function ran for a forged identity"
end

test "exploit: email takeover via an unsigned token" do
  before = email_of("bob")
  post "/account/email", params: { email: "attacker@evil.example" },
                         headers: { "Authorization" => "Bearer #{['bob'].pack('m0')}" }, as: :json
  assert_equal before, email_of("bob"), "critical function ran for an unverified token"
end

Pitfalls

  • A request.headers['X-User-Id'], a params[:user] or a ?user= value is a claim, not a credential. The vulnerable implementation trusts the header, so the caller names whoever they like.
  • A token the server never signed is not a credential. The wrong implementation base64-decodes the username out of the token and trusts it; an attacker base64-encodes the victim's name and is the victim. Decoding is not verifying.
  • Compare the MAC with ActiveSupport::SecurityUtils.secure_compare, not ==: == returns early and leaks the correct prefix through timing.
  • Put authentication in a before_action on a base controller, not in each action, or an action added later is reachable with no credential. Opt the health route out with skip_before_action, only:.
  • Authentication (who you are) is not authorization (what you may do): a verified user still needs object- and function-level checks (see the missing-authorization card).
  • Store or check real credentials server-side; this card signs the username only to keep the mechanism visible. A production app would use a vetted library, a session, or Rails' has_secure_token with a database lookup.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authentication/ruby-rails does this for you, in Docker if you do not have Ruby).
  2. Against your own API, send X-User-Id set to another user with no token, then a Bearer token you built without the server's key; both must answer 401 and change nothing.
  3. Scanners do not help here: Semgrep's packs, Brakeman and CodeQL's Ruby queries report nothing about missing authentication on any implementation.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix missing authentication in Ruby on Rails
ToolRuleWhat it found here
Semgrepp/ruby, p/owasp-top-ten, p/security-auditNo findings on any implementation. No Ruby rule distinguishes an authenticator that verifies a server-issued credential from one that trusts a header or an unsigned token; the three implementations look the same to every rule.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); every Ruby rule in a semgrep-rules checkout (develop 9bbf021) run on app/ and config/, then the packs with --config p/ruby, p/owasp-top-ten and p/security-audit
CodeQLrb/sensitive-get-query, rb/missing-token-validationNo Ruby query looks for a missing or forgeable authentication step. CodeQL cannot tell that the wrong implementation never verifies the token's signature; it has no model for OpenSSL::HMAC verification either, so it says nothing about any implementation.Checked: github/codeql main at b5b8165; query list and Ruby library source read, not executed
Brakemangeneral check setBrakeman 8.1.0No warnings about authentication on any implementation. Brakeman has no check for an endpoint that skips authentication or trusts an unverified identity; its token and session checks look for other patterns. With -A it also reports ForceSSL because the card has no config/environments/production.rb, which the card leaves out as the other Rails cards do.Checked: Brakeman 8.1.0 in the ruby:4.0.7 image; brakeman -A -q --no-pager -f json /card (all checks, optional ones included)

No Ruby scanner rule looks for an endpoint that skips authentication or trusts an unverified identity. The regression tests are the control: they prove a forged header and an unsigned token are both refused.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageRuby
RuntimeRuby 4.0.7
FrameworkRuby on Rails 8.1.4 (API controller, before_action, OpenSSL::HMAC, ActiveSupport::SecurityUtils)
rails (railties, actionpack, activerecord)8.1.4
sqlite32.9.6
minitest6.0.6
minitest-reporters (JUnitReporter)1.8.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authentication/ruby-railsfolder.

Shell
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install
Shell
# Expected: every test passes
IMPL=fixed BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/account_test.rb

# Expected: an exploit test fails
IMPL=vulnerable BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/account_test.rb

# Expected: a test fails
IMPL=wrong BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/account_test.rb

Without Ruby installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=fixed -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/account_test.rb'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=vulnerable -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/account_test.rb'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=wrong -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/account_test.rb'

Each run writes a JUnit report to reports/TEST-*.xml. From the top folder of the download, node run-all.mjs missing-authentication/ruby-rails runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 26 KB

Verified

Machine
Debian 13 (trixie) in Docker, on macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • ruby 4.0.7 (ruby:4.0.7-trixie image, linux/arm64)
  • bundler 4.0.20
  • rails 8.1.4
  • minitest 6.0.6
  • minitest-reporters 1.8.0
  • semgrep 1.180.0
  • brakeman 8.1.0
Matrix
fixed: 9 of 9 tests pass; vulnerable: 2 of 9 tests fail (2 exploit tests); wrong: 2 of 9 tests fail (2 exploit tests)
Test results
fixed 9/9 pass; vulnerable 2/9 fail (2 exploit tests: the forged X-User-Id header, the email takeover via that header); wrong 2/9 fail (2 exploit tests: the unsigned bearer token, the email takeover via that token), each test run in a container with --network=none after install
Dependency freshness
Same pins as the other Rails cards: Rails 8.1.4 (2026-09-24), with uri held at 1.1.1 and prettyprint at 0.2.0 because 1.1.2 and 0.2.1 (both 2026-10-08) were three days old and are not security releases. Every other gem in Gemfile.lock is at least 13 days old. OSV reported no known vulnerabilities for any pin. Ruby 4.0.7 (2026-09-15) is the latest 4.0 release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. CWE-306: Missing Authentication for Critical Function
  2. OWASP Top 10:2025 A07 Authentication Failures
  3. OWASP API Security Top 10 2023: API2 Broken Authentication
  4. OWASP Authentication Cheat Sheet
  5. Rails Guides: Action Controller Overview (filters and before_action)
  6. Rails API: ActiveSupport::SecurityUtils.secure_compare
  7. Ruby standard library: OpenSSL::HMAC.hexdigest

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.