Call reset_session at sign-in, not session.clear: only reset_session issues a new session ID. Name the cookie __Host-, with secure: true, httponly: true and same_site: :lax, and end sessions on the server: expire_after for idle time on a server-side store, plus a signed-in-at time checked on every request.
- Find every sign-in, sign-up and privilege change, and call reset_session before storing the user; sign-out should call reset_session too.
- Configure the session cookie in one place: key "__Host-session", secure: true, httponly: true, same_site: :lax, and no domain.
- Prefer a server-side store (ActionDispatch::Session::CacheStore on a shared cache, or a database-backed session) so a session can be ended on the server; set expire_after to the idle timeout.
- Store the sign-in time in the session and reset it in a before_action once the absolute limit (8 hours here) has passed.
- Serve the site over HTTPS only (config.force_ssl = true), which also marks cookies Secure, and add regression tests for fixation, cookie attributes and both timeouts.
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
module SignIn
private
def sign_in(user)
session[:user_id] = user.id
session[:return_to] || "/account"
end
endWith IMPL=vulnerable, 8 of 11 tests fail (8 exploit tests).
The fix: a new session ID at sign-in
module SignIn
private
def sign_in(user)
return_to = session[:return_to] || "/account"
# reset_session deletes the old session and issues a new ID: whoever held the ID before
# sign-in holds nothing after it.
reset_session
session[:user_id] = user.id
session[:signed_in_at] = Time.current.to_i
return_to
end
endWith IMPL=fixed, 11 of 11 tests pass.
The fix: cookie attributes and timeouts
IDLE_TIMEOUT = 30.minutes
ABSOLUTE_TIMEOUT = 8.hours
# Rails' CacheStore keeps the session data on the server and only its ID in the cookie. Use a
# shared cache (Redis, Memcached, Solid Cache) when more than one process serves the app.
# expire_after is the idle timeout: every request writes the session again with that lifetime.
def self.configure(config)
config.session_store :cache_store,
cache: ActiveSupport::Cache::MemoryStore.new,
key: "__Host-session", # the browser accepts it only with Secure, Path=/ and no Domain
secure: true, httponly: true, same_site: :lax,
expire_after: IDLE_TIMEOUT
end
module AbsoluteTimeout
extend ActiveSupport::Concern
included { before_action :end_session_at_absolute_timeout }
private
def end_session_at_absolute_timeout
signed_in_at = session[:signed_in_at]
reset_session if signed_in_at && Time.current.to_i - signed_in_at >= ABSOLUTE_TIMEOUT
end
endWith IMPL=fixed, 11 of 11 tests pass.
A fix that looks safe but is not
module SignIn
private
def sign_in(user)
return_to = session[:return_to] || "/account"
session.clear # a clean session for the signed-in user... under the same ID
session[:user_id] = user.id
session[:signed_in_at] = Time.current.to_i
return_to
end
endWith IMPL=wrong, 2 of 11 tests fail (2 exploit tests).
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.
# Exploit: a session ID that outlives sign-in, cookie attributes that expose the ID, and
# sessions that never end.
test "exploit: session ID issued before sign-in does not work after the victim signs in" do
# The attacker opens the site and is given a session ID without signing in...
attacker = browser
attacker.send_request(:get, "/account", TYPED_URL)
attacker.send_request(:get, "/login", SAME_ORIGIN_PAGE)
# ...and the victim signs in on a browser that already holds it: a shared computer the
# attacker used first, or a cookie written from a sibling subdomain (a __Host- cookie can't be).
victim = attacker.clone
sign_in(victim, "alice")
assert_equal "alice", signed_in_as(victim)
refute_equal "alice", signed_in_as(attacker), "the pre-login session ID opens Alice's account"
end
test "exploit: attacker's own session planted in the victim's browser does not become the victim's" do
bob = signed_in("bob")
victim = bob.clone # Bob plants his signed-in session cookie in Alice's browser
sign_in(victim, "alice")
assert_equal "alice", signed_in_as(victim)
refute_equal "alice", signed_in_as(bob), "Bob's session now opens Alice's account"
end
test "exploit: session cookie is HttpOnly so page scripts cannot read it" do
_name, line, attributes = session_cookie(signed_in("alice"))
assert attributes.key?("httponly"), "no HttpOnly in Set-Cookie: #{line}"
end
test "exploit: session cookie is Secure so the browser never sends it over plain HTTP" do
_name, line, attributes = session_cookie(signed_in("alice"))
assert attributes.key?("secure"), "no Secure in Set-Cookie: #{line}"
end
test "exploit: session cookie is SameSite Lax or Strict" do
_name, line, attributes = session_cookie(signed_in("alice"))
assert_match(/\A(lax|strict)\z/i, attributes["samesite"].to_s, "Set-Cookie: #{line}")
end
test "exploit: session cookie has the __Host- prefix, Path=/ and no Domain" do
name, line, attributes = session_cookie(signed_in("alice"))
assert name.start_with?("__Host-"), "the session cookie is called #{name}"
assert(attributes.key?("secure") && attributes["path"] == "/" && !attributes.key?("domain"), "Set-Cookie: #{line}")
end
test "exploit: session left idle for 31 minutes no longer works" do
alice = signed_in("alice")
advance(TIMELINE["idleFor"])
assert_nil signed_in_as(alice), "the session still works after #{TIMELINE['idleFor']} idle minutes"
end
test "exploit: session kept busy stops working 8 hours after sign-in" do
alice = signed_in("alice")
TIMELINE["pollEvery"].step(TIMELINE["busyUntil"], TIMELINE["pollEvery"]) do |minutes|
advance(TIMELINE["pollEvery"])
assert_equal "alice", signed_in_as(alice), "signed out after #{minutes} busy minutes"
end
advance(TIMELINE["pastAbsoluteLimit"] - TIMELINE["busyUntil"])
assert_nil signed_in_as(alice), "the session still works #{TIMELINE['pastAbsoluteLimit']} minutes after sign-in"
end- session.clear empties the session but keeps its ID; with a server-side store whoever held the ID before sign-in holds the signed-in session after it. That is this card's wrong implementation.
- Rails' default CookieStore keeps the session data in the encrypted cookie, so the server cannot end a session: a copied cookie keeps working (until any expire_after lifetime Rails embeds in it runs out), and sign-out on one device does not end another. The Rails security guide describes these replay attacks.
- expire_after is an idle timeout: every request sets the lifetime again, so a busy session never ends without a separate absolute limit.
- secure: true without HTTPS breaks sign-in; a __Host- cookie is refused by the browser unless it is Secure, has Path=/ and no Domain.
- same_site: :none sends the cookie on cross-site requests, which re-opens CSRF and cross-site leaks; :lax is the Rails default since load_defaults 6.1.
- httponly: false lets any script on the page, including an XSS payload, read the session ID.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs session-cookies/ruby-rails does this for you, in the pinned Ruby container).
- In your own app, note the session cookie before and after sign-in and expect a different ID; check the Set-Cookie attributes; leave a session idle past the limit and expect to be signed out.
- Scanners only see session_store :cookie_store options in an initializer; nothing reported the missing reset_session or the timeouts, so keep the regression tests.
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 | ruby.rails.security.brakeman.check-cookie-store-session-security-attributes.check-cookie-store-session-security-attributes | No findings on this card: it matches session_store :cookie_store with httponly: false or secure: false, and this card uses :cache_store. On a probe with the same settings for :cookie_store in config/initializers/session_store.rb it reported both attributes.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id> |
| Semgrep | p/ruby, p/owasp-top-ten, p/security-audit | No findings about the session: p/ruby reported ruby.rails.security.audit.xss.avoid-redirect on redirect_to sign_in(user), and p/security-audit ruby.lang.security.missing-csrf-protection on ApplicationController; both are false positives here.Checked: Semgrep 1.180.0 (CE); --config p/ruby, p/owasp-top-ten and p/security-audit on app/ and config/ |
| CodeQL | rb/weak-cookie-configuration | Not expected to report anything: the query (CWE-732, CWE-1275) checks only application-wide settings such as config.action_dispatch.cookies_same_site_protection set to :none, a weak encrypted_cookie_cipher or use_authenticated_cookie_encryption = false, not session_store options. CodeQL for Ruby has no query for session fixation, cookie HttpOnly or Secure flags, or session timeouts.Checked: github/codeql main at b5b8165; query and Ruby library source read, not executed |
| Brakeman | SessionSettingsSession settings | No warnings on this card, or on a probe with session_store :cookie_store, httponly: false, secure: false in config/initializers/session_store.rb. Brakeman has no check for a missing reset_session or for session timeouts.Checked: Brakeman 8.1.0 in the ruby:4.0.7 image; brakeman -A -q /card (all checks, optional ones included) |
| Component | Version |
|---|---|
| Language | Ruby |
| Runtime | Ruby 4.0.7 |
| Framework | Ruby on Rails 8.1.4 (ActionDispatch::Session::CacheStore) |
| rails (railties, actionpack, actionview, activerecord) | 8.1.4 |
| sqlite3 | 2.9.6 (bundles SQLite 3.53.2) |
| bcrypt | 3.1.22 |
| 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 session-cookies/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/session_test.rb
# Expected: an exploit test fails
IMPL=vulnerable BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/session_test.rb
# Expected: a test fails
IMPL=wrong BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/session_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/session_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/session_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/session_test.rb'Each run writes a JUnit report to reports/TEST-*.xml. From the top folder of the download, node run-all.mjs session-cookies/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
- sqlite3 2.9.6 (SQLite 3.53.2)
- bcrypt 3.1.22
- nokogiri 1.19.4
- semgrep 1.180.0
- brakeman 8.1.0
- Matrix
- fixed: 11 of 11 tests pass; vulnerable: 8 of 11 tests fail (8 exploit tests); wrong: 2 of 11 tests fail (2 exploit tests)
- Test results
- fixed 11/11 pass; vulnerable 8/11 fail (8 exploit tests); wrong 2/11 fail (2 exploit tests: the pre-sign-in session ID and the planted session), test runs offline (--network=none) after install; node examples/fixes/run-all.mjs ruby-rails --install, results/2026-10-11-fix-rails.json
- Dependency freshness
- Rails 8.1.4 was published on 2026-09-24 and Brakeman 8.1.0 on 2026-10-01. uri 1.1.2 and prettyprint 0.2.1 (both 2026-10-08) were three days old and are not security releases, so the Gemfile holds uri at 1.1.1 and prettyprint at 0.2.0; 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.
- Notes
- The test clock is ActiveSupport::Testing::TimeHelpers (travel_to, travel); ActiveSupport::Cache::MemoryStore reads the same clock, so expire_after ends idle sessions on the test timeline. Each request in test/browser.rb is a fresh integration session that sends only the cookies the test keeps, ignoring their expiry, as an attacker's client would.
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
- OWASP Session Management Cheat Sheet
- Rails Guides: Securing Rails Applications, session fixation countermeasures
- Rails Guides: Securing Rails Applications, session expiry
- Rails Guides: Securing Rails Applications, replay attacks for CookieStore sessions
- Rails API: ActionDispatch::Session::CacheStore
- Rails Guides: configuring, config.session_store
- Brakeman: session settings
- CWE-384: Session Fixation
- CWE-613: Insufficient Session Expiration
Attack patterns: