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.
- 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.
- 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).
- 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.
- Render 401 when there is no credential or the signature does not match, and keep current_user available to actions through an attr_reader.
- 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.
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
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
endWith IMPL=vulnerable, 2 of 9 tests fail (2 exploit tests).
The fix
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)
endWith IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
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
endWith IMPL=wrong, 2 of 9 tests fail (2 exploit tests).
The authentication before_action
# 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
endThe 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.
# 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- 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.
- 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).
- 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.
- Scanners do not help here: Semgrep's packs, Brakeman and CodeQL's Ruby queries report nothing about missing authentication on any implementation.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/ruby, p/owasp-top-ten, p/security-audit | No 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 |
| CodeQL | rb/sensitive-get-query, rb/missing-token-validation | No 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 |
| Brakeman | general check setBrakeman 8.1.0 | No 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.
| Component | Version |
|---|---|
| Language | Ruby |
| Runtime | Ruby 4.0.7 |
| Framework | Ruby on Rails 8.1.4 (API controller, before_action, OpenSSL::HMAC, ActiveSupport::SecurityUtils) |
| rails (railties, actionpack, activerecord) | 8.1.4 |
| sqlite3 | 2.9.6 |
| minitest | 6.0.6 |
| minitest-reporters (JUnitReporter) | 1.8.0 |
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.
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install# 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.rbWithout 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.
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'# 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.
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
- CWE-306: Missing Authentication for Critical Function
- OWASP Top 10:2025 A07 Authentication Failures
- OWASP API Security Top 10 2023: API2 Broken Authentication
- OWASP Authentication Cheat Sheet
- Rails Guides: Action Controller Overview (filters and before_action)
- Rails API: ActiveSupport::SecurityUtils.secure_compare
- Ruby standard library: OpenSSL::HMAC.hexdigest
Attack patterns: