Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Ruby on Rails  Module 3 – Routing and controllers

Sessions, cookies, flash and Current

Keep state across Rails 8.1 requests: the encrypted session cookie, signed and encrypted cookies with safe attributes, flash messages and Current.

  • Intermediate
  • 20 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Store small state in the session and in signed or encrypted cookies, and predict what a user can read or change in each
  • Set the attributes and lifetime a cookie needs, from Secure, HttpOnly and SameSite to an expiry counted in days
  • Protect sign-in state with a server-side session record, and reset the session when privileges change
  • Show flash messages across a redirect and with flash.now on a render
  • Use Current attributes for per-request context without leaking it between requests or into jobs

Before you start

What you’ll learn · Before you start

What you will learn

  • Store small state in the session and in signed or encrypted cookies, and predict what a user can read or change in each
  • Set the attributes and lifetime a cookie needs, from Secure, HttpOnly and SameSite to an expiry counted in days
  • Protect sign-in state with a server-side session record, and reset the session when privileges change
  • Show flash messages across a redirect and with flash.now on a render
  • Use Current attributes for per-request context without leaking it between requests or into jobs

Before you start

On this page

HTTP remembers nothing between requests: a Kaksha teacher who signs in and then opens 40 pages sends 40 requests, and each one arrives carrying only the cookies her browser sends back. Rails builds four tools on top of those cookies, and picking the wrong one is how an app ends up with a 9,207-byte session that Rails refuses to send, a sign-in cookie that lasts 20 years, or one teacher’s name on another teacher’s request.

This lesson reads the recorded runs of small Rails 8.1 apps and of Kaksha with its generated sign-in code. You will store state in the session and in signed and encrypted cookies, give each cookie the attributes and the lifetime it needs, keep sign-in state on the server, show flash messages exactly once, and hold per-request context in Current without leaking it.

Kaksha remembers which centre a teacher picked without a database query. The action writes session[:centre_id] = 7, and every later request from that browser can read it back. Where that value lives is decided by the session store: Where Rails keeps a request's session. The default cookie store holds the whole session, encrypted, in one cookie in the browser; other stores keep it server-side with only an id in the cookie., and a new app uses the cookie store: the Action Controller guide calls it the default and recommended store. It keeps the whole session hash in the browser, in one cookie named after the app, so an app called Kaksha sends _kaksha_session.

The cookie is encrypted with a key derived from secret_key_base. The security guide’s point is that the session then has integrity and confidentiality at once: a user can neither read what it holds nor change it without Rails noticing. What the browser can do is keep it, send it back, and copy it. Follow one browser through five requests and two mistakes:

Interactive · message by message The session of one browser, request by request Use the step buttons to follow the browser's cookie. Each arrow is a request or a response, labelled with what travels.
The session of one browser, request by request, step 8 of 8. Messages so far: Browser to Rails: GET /centre; Rails to Your action: session[:centre_id] = 7; Rails to Browser: Set-Cookie: _kaksha_session; Browser to Rails: Cookie: _kaksha_session; Rails to Browser: same data, new characters; Your action to Rails: mock_tests_left = 2; Browser to Rails: old copy of the cookie; Your action to Rails: 5,000 characters.Browsercookie says 2RailsCookieOverflowYour actionwrote 2GET /centresession[:centre_id] = 7Set-Cookie: _kaksha_sessionCookie: _kaksha_sessionsame data, new charactersmock_tests_left = 2old copy of the cookie5,000 characters

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. No cookie yet. The first visit to Kaksha carries no _kaksha_session cookie, so Rails hands the action an empty session. Reading it gives nil for every key, and no Set-Cookie comes back for an empty session.
  2. The action writes two keys. POST /centre runs choose, which stores centre_id 7 and mock_tests_left 3. The session behaves like a hash, and nothing has been sent anywhere yet.
  3. Rails encrypts the hash. After the action, the cookie store turns the whole hash, plus a random session id, into one encrypted value of 284 characters and sends it with the attributes path=/, httponly and samesite=lax.
  4. The cookie comes back. On GET /centre the browser sends the cookie in its Cookie header. Rails decrypts it, checks it was not altered, and the action reads centre_id 7 and mock_tests_left 3 with no database query.
  5. Every read sends a new copy. Because the action loaded the session, the response carries a freshly encrypted cookie, different characters for the same data. Each encryption starts from a new random value.
  6. Spending a mock test. POST /tests lowers mock_tests_left to 2, and the new cookie the browser keeps says 2. Somebody had copied the cookie of the previous step, which still says 3.
  7. The old copy still works. The browser sends the old copy. It decrypts and verifies, because it is a genuine cookie from this app, so the action reads mock_tests_left 3 again. The server keeps no record of which copy is the latest.
  8. 4 KB is the ceiling. POST /notes stores 5,000 characters. Encrypted, the cookie would be 9,207 bytes, more than the 4,096 bytes a cookie may hold, so Rails raises ActionDispatch::Cookies::CookieOverflow instead of sending it.

The figure follows this program, a one-file Rails app whose browser is an integration session, the same tool Rails’ own integration tests use. It keeps every cookie a response sets and sends it with the next request:

The session in one cookie: written, read, replayed, overflowing Ruby · session_cookie.rb
# The session of a Rails 8.1 app travels in one encrypted cookie. This one-file app is called Kaksha, so its cookie is
# _kaksha_session. An integration session, the tool Rails' own integration tests use, plays the browser: it keeps the
# cookies each response sets and sends them back with the next request. After four ordinary requests it sends an
# old copy of the cookie (a replay), then tries to keep 5,000 characters in the session.
require "rails"
require "action_controller/railtie"
require "action_dispatch/testing/integration"

module Kaksha
  class Application < Rails::Application
    config.load_defaults 8.1
    config.eager_load = false
    config.logger = Logger.new(nil)
    config.secret_key_base = "kaksha-one-file-example-" * 3 # a real app keeps this in its credentials
    config.hosts << "kaksha.example"
    # These requests come from a script, not from a form, so they carry no form token; a real app keeps this check.
    config.action_controller.allow_forgery_protection = false
    config.action_dispatch.show_exceptions = :none # let an exception reach this script instead of an error page
  end
end
Kaksha::Application.initialize!

class CentreController < ActionController::Base
  def choose
    session[:centre_id] = 7
    session[:mock_tests_left] = 3
    render plain: "centre 7 chosen"
  end

  def show
    render plain: "centre_id=#{session[:centre_id].inspect} mock_tests_left=#{session[:mock_tests_left].inspect}"
  end

  def take_test
    session[:mock_tests_left] -= 1
    render plain: "test started, mock_tests_left=#{session[:mock_tests_left]}"
  end

  def save_notes
    session[:notes] = "x" * 5000
    render plain: "notes saved"
  end
end

Kaksha::Application.routes.draw do
  post "/centre", to: "centre#choose"
  get "/centre", to: "centre#show"
  post "/tests", to: "centre#take_test"
  post "/notes", to: "centre#save_notes"
end

browser = ActionDispatch::Integration::Session.new(Kaksha::Application)
browser.host! "kaksha.example"

# Sends one request and prints the answer, then each Set-Cookie line with its encrypted value left out: the value is
# different on every response, because each encryption starts from a new random number. Its length is printed instead,
# as the server wrote it, before the characters + / and = were escaped for the header.
visit = lambda do |verb, path|
  browser.public_send(verb, path)
  response = browser.response
  puts "#{verb.upcase} #{path} -> #{response.status} #{response.body}"
  Array(response.headers["set-cookie"]).flat_map { it.split("\n") }.each do |line|
    name, rest = line.split("=", 2)
    value, attributes = rest.split("; ", 2)
    puts "  Set-Cookie: #{name}=<encrypted, #{CGI.unescape(value).size} characters>; #{attributes}"
  end
end

visit.(:get, "/centre")
visit.(:post, "/centre")
visit.(:get, "/centre")
copy = browser.cookies["_kaksha_session"] # someone keeps a copy of the cookie as it is now
visit.(:post, "/tests")
visit.(:get, "/centre")

puts "The browser sends the old copy back:"
browser.cookies["_kaksha_session"] = copy
visit.(:get, "/centre")

begin
  visit.(:post, "/notes")
rescue ActionDispatch::Cookies::CookieOverflow => error
  puts "POST /notes -> #{error.class}: #{error.message}"
end

Output

GET /centre -> 200 centre_id=nil mock_tests_left=nil
POST /centre -> 200 centre 7 chosen
  Set-Cookie: _kaksha_session=<encrypted, 284 characters>; path=/; httponly; samesite=lax
GET /centre -> 200 centre_id=7 mock_tests_left=3
  Set-Cookie: _kaksha_session=<encrypted, 284 characters>; path=/; httponly; samesite=lax
POST /tests -> 200 test started, mock_tests_left=2
  Set-Cookie: _kaksha_session=<encrypted, 284 characters>; path=/; httponly; samesite=lax
GET /centre -> 200 centre_id=7 mock_tests_left=2
  Set-Cookie: _kaksha_session=<encrypted, 284 characters>; path=/; httponly; samesite=lax
The browser sends the old copy back:
GET /centre -> 200 centre_id=7 mock_tests_left=3
  Set-Cookie: _kaksha_session=<encrypted, 284 characters>; path=/; httponly; samesite=lax
POST /notes -> ActionDispatch::Cookies::CookieOverflow: _kaksha_session cookie overflowed with size 9207 bytes

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby session_cookie.rb

Three things in that output decide what belongs in a session. The cookie came back as 284 encrypted characters with path=/, httponly and samesite=lax, and every response that read the session sent a fresh copy. The old copy worked: it restored mock_tests_left=3, because a genuine cookie decrypts whether or not it is the latest one, and the server keeps no list of the copies it has issued. And 5,000 characters of notes would have made a 9,207-byte cookie, more than the 4,096 bytes a cookie may hold, so Rails raised ActionDispatch::Cookies::CookieOverflow and sent nothing.

Keeping a balance in the session

session[:mock_tests_left] -= 1 looks like it spends a test, but the run above got the test back by sending an old cookie. Keep the count in a table and change it there, for example current_student.decrement!(:mock_tests_left), so a copied cookie has nothing to restore.

So a session holds small things that are safe to replay: an id, a locale, the page to return to after signing in. Records, balances and anything worth money belong in the database, with at most their id in the session. Rails ships other stores too: the cache store keeps sessions in the Rails cache, and the activerecord-session_store gem keeps them in a table, each with only an id in the cookie.

Key idea

The cookie store keeps the whole session in the browser, encrypted: Rails can trust what comes back, but it cannot take back a copy it once sent, so keep only small, replay-safe values there.

Checkpoint

Check: The session as an encrypted cookie

  1. Question 1 of 2 In the recorded run, a copy of _kaksha_session taken when mock_tests_left was 3 is sent back after a request lowered it to 2. What does the action read?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 3, because the cookie is a genuine, decryptable copy and the server keeps no record of the latest one

    A cookie-store session lives entirely in the browser's cookie. Any genuine copy decrypts and verifies, so a replayed copy restores its old values. Keep spendable counts in the database, not the session.

  2. Question 2 of 2 How many bytes may one cookie hold before Rails raises CookieOverflow rather than send it?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 4096 bytes

    MAX_COOKIE_SIZE is 4,096 bytes. Storing 5,000 characters made a 9,207-byte cookie in the run, so Rails raised ActionDispatch::Cookies::CookieOverflow and sent nothing.

Signed cookies refuse edits, encrypted cookies also hide the value

Not every cookie needs the session. Kaksha remembers a colour theme, the batch a teacher opened last and the referral code she arrived with, and each goes into a different cookie jar: cookies[:theme] writes a plain cookie, cookies.signed[:batch_id] a signed cookie: A cookie written with cookies.signed: its value is readable but carries a signature made from secret_key_base, so any change the user makes to it is detected and the value reads as nil., and cookies.encrypted[:referral] an encrypted cookie: A cookie written with cookies.encrypted: its value is both encrypted and signed with keys from secret_key_base, so the user can neither read it nor change it without it reading as nil.. This program writes all three, then does what anyone can do in the browser’s developer tools: it reads them, changes one value in each, and sends them back.

Plain, signed and encrypted cookies, read and edited Ruby · cookie_jars.rb
# Three cookie jars of Rails 8.1 side by side: a plain cookie, a signed one and an encrypted one, written by one action
# and read back by another. The script then does what any user can do in the browser's developer tools: it reads the
# values it was sent, changes one in each cookie, and sends them back.
require "rails"
require "action_controller/railtie"
require "action_dispatch/testing/integration"

module Kaksha
  class Application < Rails::Application
    config.load_defaults 8.1
    config.eager_load = false
    config.logger = Logger.new(nil)
    config.secret_key_base = "kaksha-one-file-example-" * 3 # a real app keeps this in its credentials
    config.hosts << "kaksha.example"
  end
end
Kaksha::Application.initialize!

class PreferencesController < ActionController::Base
  def write
    cookies[:theme] = "dark"
    cookies.signed[:batch_id] = 12
    cookies.encrypted[:referral] = "KAKSHA-REF-4471"
    head :no_content
  end

  def read
    render plain: "theme=#{cookies[:theme].inspect} batch_id=#{cookies.signed[:batch_id].inspect} " \
                  "referral=#{cookies.encrypted[:referral].inspect}"
  end
end

Kaksha::Application.routes.draw do
  get "/write", to: "preferences#write"
  get "/read", to: "preferences#read"
end

browser = ActionDispatch::Integration::Session.new(Kaksha::Application)
browser.host! "kaksha.example"

browser.get "/write"
Array(browser.response.headers["set-cookie"]).flat_map { it.split("\n") }.each do |line|
  name, rest = line.split("=", 2)
  value = rest.split("; ").first
  value = "<encrypted, changes on every response>" if name == "referral"
  puts "Set-Cookie #{name}: #{value}"
end

signed = browser.cookies["batch_id"] # the jar hands back the value as the browser stores it
data, digest = signed.split("--")
envelope = Base64.strict_decode64(data)
message = JSON.parse(envelope).dig("_rails", "message")
puts "batch_id before the --, decoded: #{envelope}"
puts "Its message #{message.inspect}, decoded: #{Base64.strict_decode64(message)}"
puts "After the --: a signature of #{digest.size} hex digits"

browser.get "/read"
puts "As sent:  #{browser.response.body}"

# The user edits each cookie: a new theme, batch 13 with the old signature, and one character of the encrypted value.
browser.cookies["theme"] = "light"
forged = Base64.strict_encode64(envelope.sub(message.to_json, Base64.strict_encode64("13").to_json))
browser.cookies["batch_id"] = "#{forged}--#{digest}"
encrypted = browser.cookies["referral"]
browser.cookies["referral"] = encrypted.sub(/\A./) { it == "A" ? "B" : "A" }

browser.get "/read"
puts "Edited:   #{browser.response.body}"

Output

Set-Cookie theme: dark
Set-Cookie batch_id: eyJfcmFpbHMiOnsibWVzc2FnZSI6Ik1UST0iLCJleHAiOm51bGwsInB1ciI6ImNvb2tpZS5iYXRjaF9pZCJ9fQ%3D%3D--fc803abe8cc4cd54611e927d29ecc30f9001817d
Set-Cookie referral: <encrypted, changes on every response>
batch_id before the --, decoded: {"_rails":{"message":"MTI=","exp":null,"pur":"cookie.batch_id"}}
Its message "MTI=", decoded: 12
After the --: a signature of 40 hex digits
As sent:  theme="dark" batch_id=12 referral="KAKSHA-REF-4471"
Edited:   theme="light" batch_id=nil referral=nil

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby cookie_jars.rb

The plain cookie went out as dark, came back as light, and the app believed it. The signed cookie is readable: the part before -- is Base64 of a small envelope, {"_rails":{"message":"MTI=",...}}, and MTI= is Base64 for 12. After the -- comes a signature of 40 hex digits, made with a key the browser never sees, so batch 13 with the old signature read as nil. The encrypted value showed nothing readable at all, and one changed character made it nil too. You can decode the recorded envelope yourself in the Base64 decoder after writing %3D%3D as ==; never paste the cookie of a real signed-in session into any website.

Written with Can the user read it? Can the user change it unnoticed? Fits
cookies[:theme] Yes Yes Preferences with no consequence
cookies.signed[:batch_id] Yes, it is Base64 No, it reads as nil Ids the user may see
cookies.encrypted[:referral] No No, it reads as nil Values the user should not see
session[:centre_id] No No, the session is encrypted Small state for every request

Both protected jars get their keys from secret_key_base, which a production app keeps in config/credentials.yml.enc. Anyone who holds that secret can write cookies your app will trust, and changing it makes every existing session and protected cookie unreadable at once.

Signing a value the user must not see

cookies.signed[:fee_due] = 4500 stops a parent from lowering the amount, but anyone with the cookie can decode it. Use cookies.encrypted when the value is private, and the database when it matters after this visit.

Checkpoint

Check: Plain, signed and encrypted cookies

  1. Question 1 of 2 A signed cookie written with cookies.signed[:batch_id] = 12 is sent to the browser. What can its owner do with it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Read 12 by decoding the Base64 before the --, but not change it to 13 without the value reading as nil

    A signed cookie carries a readable Base64 value and a signature. The run decoded MTI= as 12, and batch 13 with the old signature read as nil. Use cookies.encrypted when the value must also be hidden.

  2. Question 2 of 2 Kaksha must store a discount a parent should neither see nor change, and keep it only for this visit. Which fits best?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: cookies.encrypted (or the session), because the value must be both hidden and tamper-proof

    When a value must be hidden and unchangeable, cookies.encrypted (or the session, which is encrypted) fits. A signed cookie would leave the discount readable.

Secure needs HTTPS enforcement, and permanent means 20 years

The sign-in code that Rails 8 generates writes its cookie with one line: cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }. Each option is a cookie attribute, an instruction the browser follows when it decides where the cookie may go and how long to keep it:

  • Secure: send the cookie over HTTPS only, so nobody on the network path can read it.
  • HttpOnly: keep it away from JavaScript, so a script injected into a page cannot read it from document.cookie.
  • SameSite: with Lax, leave it off cross-site requests except top-level navigations with a safe method such as GET. OWASP treats this as defence in depth: Rails still checks a CSRF token on every request that changes data.
  • Expires or Max-Age: keep it until that time. A cookie with neither is a session cookie, kept until the browser decides the browsing session is over.
  • Path and Domain: which paths and hosts get it. Rails writes path=/ and no Domain, so the cookie goes back only to the host that set it.

The next program runs the generator’s line in production mode twice: once as rails new leaves config/environments/production.rb, with config.force_ssl commented out, and once with config.force_ssl = true. Each run signs in over http:// and over https://, then writes the same cookie with expires: 14.days:

Cookie attributes with and without force_ssl, permanent and 14 days Ruby · cookie_flags.rb
# What config.force_ssl changes in the cookies of a Rails 8.1 app in production. The file runs itself twice in
# production mode: once as `rails new` leaves production.rb (force_ssl commented out, so off) and once with
# config.force_ssl = true. Each run signs in the way the authentication generator's code does (a signed, permanent
# session_id cookie) and with the same cookie set to expire in 14 days, over http:// and over https://.
require "rbconfig"

unless ENV["KAKSHA_FORCE_SSL"]
  %w[false true].each do |force_ssl|
    puts "config.force_ssl = #{force_ssl}"
    system({ "KAKSHA_FORCE_SSL" => force_ssl }, RbConfig.ruby, __FILE__, exception: true)
  end
  exit
end

ENV["RAILS_ENV"] = "production"
require "rails"
require "action_controller/railtie"
require "action_dispatch/testing/integration"
require "active_support/testing/time_helpers"

module Kaksha
  class Application < Rails::Application
    config.load_defaults 8.1
    config.eager_load = false
    config.logger = Logger.new(nil)
    config.secret_key_base = "kaksha-one-file-example-" * 3 # production keeps this in its credentials
    config.force_ssl = ENV["KAKSHA_FORCE_SSL"] == "true"
    # These requests come from a script, not from a form, so they carry no form token; a real app keeps this check.
    config.action_controller.allow_forgery_protection = false
  end
end
Kaksha::Application.initialize!

class SessionsController < ActionController::Base
  def create
    session[:centre_id] = 7
    if params[:days]
      cookies.signed[:session_id] = { value: 41, httponly: true, same_site: :lax, expires: params[:days].to_i.days }
    else
      cookies.signed.permanent[:session_id] = { value: 41, httponly: true, same_site: :lax } # as generated
    end
    head :no_content
  end

  def show
    render plain: "session_id read as #{cookies.signed[:session_id].inspect}"
  end
end

Kaksha::Application.routes.draw { resource :session, only: %i[create show] }

browser = ActionDispatch::Integration::Session.new(Kaksha::Application)

# Prints the status (and where a redirect points, and the HSTS header), then each cookie with its value left out and
# its expiry as the time from now.
visit = lambda do |verb, url|
  browser.public_send(verb, url)
  response = browser.response
  extra = response.location ? " to #{response.location}" : ""
  extra += ", strict-transport-security: #{response.headers["strict-transport-security"]}" if response.headers["strict-transport-security"]
  puts "  #{verb.upcase} #{url} -> #{response.status}#{extra}"
  Array(response.headers["set-cookie"]).flat_map { it.split("\n") }.each do |line|
    name, rest = line.split("=", 2)
    attributes = rest.split("; ").drop(1).map do |attribute|
      next attribute unless attribute.start_with?("expires=")
      "expires in #{((Time.httpdate(attribute.delete_prefix("expires=")) - Time.now) / 1.day).round} days"
    end
    puts "    #{name}: #{attributes.join("; ")}"
  end
end

visit.(:post, "http://kaksha.example/session")
visit.(:post, "https://kaksha.example/session")
permanent = browser.cookies["session_id"]
visit.(:post, "https://kaksha.example/session?days=14")
fortnight = browser.cookies["session_id"]

if ENV["KAKSHA_FORCE_SSL"] == "true"
  include ActiveSupport::Testing::TimeHelpers
  travel 15.days do
    puts "Fifteen days later, a copy of each session_id cookie is sent back:"
    browser.cookies["session_id"] = fortnight
    browser.get "https://kaksha.example/session"
    puts "  the 14-day cookie: #{browser.response.body}"
    browser.cookies["session_id"] = permanent
    browser.get "https://kaksha.example/session"
    puts "  the permanent cookie: #{browser.response.body}"
  end
end

Output

config.force_ssl = false
  POST http://kaksha.example/session -> 204
    session_id: path=/; expires in 7305 days; httponly; samesite=lax
    _kaksha_session: path=/; httponly; samesite=lax
  POST https://kaksha.example/session -> 204
    session_id: path=/; expires in 7305 days; httponly; samesite=lax
    _kaksha_session: path=/; httponly; samesite=lax
  POST https://kaksha.example/session?days=14 -> 204
    session_id: path=/; expires in 14 days; httponly; samesite=lax
    _kaksha_session: path=/; httponly; samesite=lax
config.force_ssl = true
  POST http://kaksha.example/session -> 308 to https://kaksha.example/session
  POST https://kaksha.example/session -> 204, strict-transport-security: max-age=63072000; includeSubDomains
    session_id: path=/; expires in 7305 days; httponly; samesite=lax; secure
    _kaksha_session: path=/; secure; httponly; samesite=lax
  POST https://kaksha.example/session?days=14 -> 204, strict-transport-security: max-age=63072000; includeSubDomains
    session_id: path=/; expires in 14 days; httponly; samesite=lax; secure
    _kaksha_session: path=/; secure; httponly; samesite=lax
Fifteen days later, a copy of each session_id cookie is sent back:
  the 14-day cookie: session_id read as nil
  the permanent cookie: session_id read as 41

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby cookie_flags.rb

Without force_ssl, neither cookie carried Secure, not even on the HTTPS request. With it, the plain HTTP request got a 308 redirect to HTTPS, every HTTPS response carried a two-year HSTS header (max-age=63072000), and both cookies were marked secure: the ActionDispatch::SSL middleware does all three. An app behind a proxy that ends TLS also needs config.assume_ssl = true, because the configuring guide notes that such requests otherwise reach Rails looking like plain HTTP. A default rails new sets the app up for Kamal and leaves both lines commented out, while rails new --skip-kamal writes both switched on, so check which of the two your production.rb holds before you deploy.

permanent set an expiry 7,305 days away, 20 years. The draft revision of the cookie standard, RFC 6265bis, tells browsers to cut longer lifetimes down, saying the limit “SHOULD NOT be greater than 400 days”, so a browser that follows it keeps that cookie for 400 days at most. OWASP goes further: use cookies without an expiry when sign-in does not need to survive the browser, and always end sessions on the server. A lifetime of expires: 14.days, written again on a later visit if you want a returning teacher to stay signed in, is far closer to that advice. The run also shows a second safeguard: Rails signs the expiry into the cookie, so fifteen days later the 14-day copy read as nil even though it was sent, while the permanent copy still read 41.

These defaults arrived one release at a time, and an app gets each one only when config.load_defaults names that release or a later one:

Timeline · 7 events in order How the cookie defaults of Rails changed, release by release Walk through the releases in order. An app gets each change only when config.load_defaults names that release or a later one.
  1. Rails 4.0

    The session cookie is encrypted

    The cookie store encrypts the session with a key derived from secret_key_base, so a user can neither read nor change what the session holds. The CookieStore documentation names Rails 4 as the release that made this the default.

  2. Rails 5.2

    Authenticated encryption

    use_authenticated_cookie_encryption switches cookies from the AES-256-CBC cipher to AES-256-GCM, which detects any change to the encrypted text on its own.

  3. Rails 6.0

    Each cookie names its purpose

    use_cookies_with_metadata writes signed and encrypted cookies with the cookie's name inside them, as "pur": "cookie.batch_id" in the envelope that the cookie jar example decodes.

  4. Rails 6.1

    SameSite=Lax unless you say otherwise security

    cookies_same_site_protection becomes :lax, so every cookie Rails writes, the session included, carries samesite=lax: browsers leave it off cross-site requests, except top-level navigations with a safe method such as GET.

  5. Rails 7.0

    JSON values and SHA256 keys

    cookies_serializer becomes :json instead of Marshal, so signed and encrypted cookies can hold strings, numbers, arrays and hashes, and key_generator_hash_digest_class derives cookie keys with SHA256.

  6. Rails 8.0

    A generator for sign-in new generator

    bin/rails generate authentication writes a session-based sign-in: a sessions table, and a signed, permanent session_id cookie that holds a row id of that table.

  7. Rails 8.1

    Where a new app starts this track

    A new app says config.load_defaults 8.1, which switches on every change above. The 8.1 defaults add none of their own for cookies. An upgraded app that still says 6.0 sends cookies without a SameSite attribute.

The timeline has 7 events, from Rails 4.0 to Rails 8.1.

Checkpoint

Check: Cookie attributes and lifetime

  1. Question 1 of 2 In the production run with config.force_ssl commented out, did the session_id cookie carry the Secure attribute on the HTTPS request?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No: without force_ssl, no cookie was marked secure, even over HTTPS

    Secure is added by ActionDispatch::SSL, which config.force_ssl enables. With it off, the run marked no cookie secure; with it on, both cookies were secure and HTTP was redirected with a 308 and an HSTS header.

  2. Question 2 of 2 cookies.signed.permanent set an expiry 7,305 days away. What does a browser that follows RFC 6265bis do with it?
    Show the answer: Question 2 of 2

    Answer: Cut the lifetime to about 400 days, the recommended upper limit

    permanent means a 20-year expiry (7,305 days in the run). RFC 6265bis asks browsers to cap lifetimes at about 400 days, so prefer a lifetime in days with server-side checks, such as expires 14.days renewed on return.

The Action Controller guide signs a user in with session[:current_user_id] = user.id, and right after that it recommends reset_session after logging in. The reason is session fixation: an attacker who manages to plant a session cookie in a victim’s browser before she signs in would otherwise share her signed-in session afterwards. OWASP states the rule more broadly: renew the session identifier after any privilege change, such as signing in, a password change or a switch to an admin role. reset_session clears every key of the session, so read anything you still need, such as the page to return to, before calling it.

The code of bin/rails generate authentication keeps sign-in state elsewhere. It creates a row in a sessions table at every sign-in and writes that row’s id into a new signed session_id cookie. The _kaksha_session cookie it leaves alone holds the page to return to, the flash and the session’s CSRF token; the signed-in state itself lives in the row and the session_id cookie. Step through one teacher’s visit:

Interactive · message by message Signing in and out with the generated authentication Step through one teacher's visit. The middle column is the generated code; the right column is the sessions table.
Signing in and out with the generated authentication, step 8 of 8. Messages so far: Browser to Kaksha: GET /; Kaksha to Browser: 302 to /session/new; Browser to Kaksha: POST /session (wrong); Kaksha to Database: INSERT sessions row 1; Kaksha to Browser: Set-Cookie: session_id; Kaksha to Database: find_by(id: 1); Kaksha to Database: DELETE row 1; Browser to Kaksha: old copy of session_id.Browsersession_id deletedKaksharow 1 is gone: 302Database0 rowsGET /302 to /session/newPOST /session (wrong)INSERT sessions row 1Set-Cookie: session_idfind_by(id: 1)DELETE row 1old copy of session_id

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Not signed in yet. GET / reaches require_authentication. There is no session_id cookie, so the concern stores the URL in session[:return_to_after_authenticating] and redirects to the sign-in page.
  2. Off to the sign-in form. The 302 points to /session/new and carries a _kaksha_session cookie, because the session now holds the return address.
  3. A wrong password. POST /session with the wrong password: User.authenticate_by finds no match, so the action redirects back with flash[:alert] set to "Try another email address or password."
  4. The right password. This time authenticate_by returns the user, and start_new_session_for creates row 1 in the sessions table with the browser's IP address and user agent.
  5. A signed session_id cookie. The response sets session_id: the row id 1, signed, permanent (it expires in 7,305 days), httponly and samesite=lax. The redirect goes to the return address, /.
  6. Every request finds the row. GET / sends both cookies. resume_session reads cookies.signed[:session_id], loads row 1 into Current.session, and Current.user is the user the row belongs to.
  7. Signing out deletes the row. DELETE /session runs terminate_session: Current.session.destroy removes row 1, and cookies.delete(:session_id) sends the cookie back empty with max-age=0. The answer is a 303 to the sign-in page.
  8. An old copy is refused. A copy of the session_id cookie made before signing out is sent again. Its signature is still valid, but Session.find_by(id: 1) finds nothing, so the request is redirected to the sign-in page.

The recorded run behind it signs in through the generated SessionsController of Kaksha. An integration session plays the browser again, driven by this script:

kaksha/script/sign_in_tour.rb: a browser for the generated sign-in Ruby · kaksha/script/sign_in_tour.rb
# Signs a teacher in and out of Kaksha through the code `bin/rails generate authentication` wrote, the way a browser
# would, and prints the cookies each response sets and the rows of the sessions table. An integration session, the
# tool of Rails' integration tests, plays the browser: it keeps cookies between requests and follows no redirect by
# itself. Run it with: bin/rails runner --skip-executor script/sign_in_tour.rb
require "action_dispatch/testing/integration"

User.create!(email_address: "asha@kaksha.example", password: "example-password-1")

# allow_browser in ApplicationController turns away browsers without modern features, so the requests say they come
# from a current Chrome.
CHROME = { "User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " \
                           "(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" }

browser = ActionDispatch::Integration::Session.new(Rails.application)
browser.host! "localhost"

# Prints the status, where a redirect points, and each cookie the response sets: its name, then its attributes, with
# the value left out and an expiry shown as the time from now.
visit = lambda do |verb, path, **options|
  browser.public_send(verb, path, headers: CHROME.merge(options.delete(:headers) || {}), **options)
  response = browser.response
  puts "#{verb.upcase} #{path} -> #{response.status}#{" to #{response.location}" if response.location}"
  Array(response.headers["set-cookie"]).flat_map { it.split("\n") }.each do |line|
    name, rest = line.split("=", 2)
    value, *attributes = rest.split("; ")
    attributes.map! do |attribute|
      next attribute unless attribute.start_with?("expires=")
      days = ((Time.httpdate(attribute.delete_prefix("expires=")) - Time.now) / 1.day).round
      days.negative? ? "expires in the past" : "expires in #{days} days"
    end
    puts "  Set-Cookie: #{name}=#{value.empty? ? "(empty)" : "…"}; #{attributes.join("; ")}"
  end
end

visit.(:get, "/")
visit.(:get, "/session/new")
token = browser.response.body[/<meta name="csrf-token" content="([^"]+)"/, 1] # the form token, as a browser has it

visit.(:post, "/session", params: { email_address: "asha@kaksha.example", password: "wrong" },
                          headers: { "X-CSRF-Token" => token })
puts "  flash[:alert] = #{browser.flash[:alert].inspect}"

visit.(:post, "/session", params: { email_address: "asha@kaksha.example", password: "example-password-1" },
                          headers: { "X-CSRF-Token" => token })
row = Session.sole
puts "  sessions table: id #{row.id}, user #{row.user.email_address}, ip #{row.ip_address}"
copy = browser.cookies["session_id"] # someone keeps a copy of the sign-in cookie

visit.(:get, "/")
puts "  #{browser.response.body}"

visit.(:delete, "/session", headers: { "X-CSRF-Token" => token })
puts "  sessions table: #{Session.count} rows"

puts "The browser sends the old copy of session_id back:"
browser.cookies["session_id"] = copy
visit.(:get, "/")

The session builds the development database from db/schema.rb, then runs the script with bin/rails runner:

Signing in and out of Kaksha with the generated code Ruby · sessions/auth_session.rb
# Kaksha after `bin/rails generate authentication` (users and sessions tables, the Authentication concern) and one
# added page, DashboardController#show at /. db:prepare builds the development database from db/schema.rb; the
# runner script then signs in and out through the generated SessionsController. --skip-executor leaves the script
# outside Rails' executor, so each request runs in an executor of its own, as it does when Puma serves it.
require_relative "session"

Session.run(<<~SH)
  cd ../kaksha
  bin/rails db:prepare
  bin/rails runner --skip-executor script/sign_in_tour.rb
SH

Output

$ cd ../kaksha
$ bin/rails db:prepare
$ bin/rails runner --skip-executor script/sign_in_tour.rb
GET / -> 302 to http://localhost/session/new
  Set-Cookie: _kaksha_session=…; path=/; httponly; samesite=lax
GET /session/new -> 200
  Set-Cookie: _kaksha_session=…; path=/; httponly; samesite=lax
POST /session -> 302 to http://localhost/session/new
  Set-Cookie: _kaksha_session=…; path=/; httponly; samesite=lax
  flash[:alert] = "Try another email address or password."
POST /session -> 302 to http://localhost/
  Set-Cookie: session_id=…; path=/; expires in 7305 days; httponly; samesite=lax
  Set-Cookie: _kaksha_session=…; path=/; httponly; samesite=lax
  sessions table: id 1, user asha@kaksha.example, ip 127.0.0.1
GET / -> 200
  Signed in as asha@kaksha.example
DELETE /session -> 303 to http://localhost/session/new
  Set-Cookie: _kaksha_session=…; path=/; httponly; samesite=lax
  Set-Cookie: session_id=(empty); path=/; max-age=0; expires in the past; samesite=lax
  sessions table: 0 rows
The browser sends the old copy of session_id back:
GET / -> 302 to http://localhost/session/new
  Set-Cookie: _kaksha_session=…; path=/; httponly; samesite=lax

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby auth_session.rb

Signing out by deleting the cookie only

cookies.delete(:session_id) removes the browser’s cookie, but a copy taken earlier keeps naming a valid row. OWASP says the same of any session: deleting the cookie does not invalidate a stolen copy. Destroy the record too, as the generated terminate_session does with Current.session.destroy.

The wrong password left only a flash alert. The right one created row 1 and a session_id cookie that expires in 7,305 days with httponly and samesite=lax, and no secure on this plain HTTP development run. Signing out did two things: terminate_session destroyed the row and deleted the cookie. The final request is the one that matters. It sent a copy of session_id made before signing out; the signature was still valid, but the row it named was gone, so the app sent the visitor back to the sign-in page. Compare the replayed session cookie of the first section, which nothing on the server could refuse.

Key idea

Keep sign-in state on the server: the signed cookie only names a row, signing out deletes that row, and a copied cookie then names nothing.

Checkpoint

Check: Server-side sign-in state

  1. Question 1 of 2 After signing out of Kaksha, a copy of the session_id cookie made before sign-out is sent again. What happens in the recorded run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The app redirects to the sign-in page, because the sessions row it names was destroyed

    The generated sign-out destroys the sessions row and deletes the cookie. A copied cookie keeps a valid signature but names a row that no longer exists, so the app sends the visitor to sign in again.

  2. Question 2 of 2 An app keeps the signed-in user as session[:current_user_id]. When should it call reset_session?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: After a privilege change such as signing in, to prevent session fixation

    The Action Controller guide recommends reset_session after logging in, and OWASP asks for a new session id after any privilege change. Read anything you still need from the session before resetting it.

A flash message survives exactly one redirect

When the password was wrong, the generated SessionsController answered with redirect_to new_session_path, alert: "Try another email address or password.", and the sign-in page showed the message. That message travelled in the flash: A part of the session for a short message that one action sets and the very next request can read, after which Rails removes it; flash.now makes the message available on the current render only., a part of the session that the very next action can read and that Rails then removes. This program answers a notice form three ways, and every page starts with the flash it shows, as a layout would:

The flash after a redirect, after a render and with flash.now Ruby · flash_demo.rb
# The flash of Rails 8.1 after a redirect, after a render, and with flash.now. Three actions answer a form for a
# notice: one saves and redirects, one fails and renders the form again with flash[:alert], and one fails and renders
# with flash.now[:alert]. Every page starts with the flash it shows, as an app's layout would.
require "rails"
require "action_controller/railtie"
require "action_dispatch/testing/integration"

module Kaksha
  class Application < Rails::Application
    config.load_defaults 8.1
    config.eager_load = false
    config.logger = Logger.new(nil)
    config.secret_key_base = "kaksha-one-file-example-" * 3 # a real app keeps this in its credentials
    config.hosts << "kaksha.example"
    # These requests come from a script, not from a form, so they carry no form token; a real app keeps this check.
    config.action_controller.allow_forgery_protection = false
  end
end
Kaksha::Application.initialize!

class NoticesController < ActionController::Base
  def index
    page "the notice board"
  end

  def create
    redirect_to "/notices", notice: "Notice published"
  end

  def create_with_flash
    flash[:alert] = "Title can't be blank"
    page "the form again", status: :unprocessable_content
  end

  def create_with_flash_now
    flash.now[:alert] = "Title can't be blank"
    page "the form again", status: :unprocessable_content
  end

  private
    def page(text, status: :ok)
      shown = flash.map { |name, message| "#{name}: #{message}" }.join(", ").presence || "no flash"
      render plain: "[#{shown}] #{text}", status: status
    end
end

Kaksha::Application.routes.draw do
  get "/notices", to: "notices#index"
  post "/notices", to: "notices#create"
  post "/notices/blank", to: "notices#create_with_flash"
  post "/notices/blank-now", to: "notices#create_with_flash_now"
end

browser = ActionDispatch::Integration::Session.new(Kaksha::Application)
browser.host! "kaksha.example"

visit = lambda do |verb, path|
  browser.public_send(verb, path)
  response = browser.response
  shown = response.redirect? ? "redirect to #{response.location}" : response.body
  puts "#{verb.upcase} #{path} -> #{response.status} #{shown}"
end

puts "Saved, then a redirect:"
visit.(:post, "/notices")
visit.(:get, "/notices")
visit.(:get, "/notices")
puts "Not saved, flash[:alert] and render:"
visit.(:post, "/notices/blank")
visit.(:get, "/notices")
puts "Not saved, flash.now[:alert] and render:"
visit.(:post, "/notices/blank-now")
visit.(:get, "/notices")

Output

Saved, then a redirect:
POST /notices -> 302 redirect to http://kaksha.example/notices
GET /notices -> 200 [notice: Notice published] the notice board
GET /notices -> 200 [no flash] the notice board
Not saved, flash[:alert] and render:
POST /notices/blank -> 422 [alert: Title can't be blank] the form again
GET /notices -> 200 [alert: Title can't be blank] the notice board
Not saved, flash.now[:alert] and render:
POST /notices/blank-now -> 422 [alert: Title can't be blank] the form again
GET /notices -> 200 [no flash] the notice board

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby flash_demo.rb

After the redirect, the notice appeared on the next page and was gone from the one after it. The second way is a common slip: flash[:alert] followed by render showed the alert on the form, then again on the notice board, because a render is not a new request, so the message waits for the next one. flash.now[:alert] showed it once, on the render that set it. When a message must outlive a second redirect, flash.keep carries it one request further.

Written with Shown on this response Shown on the next request
redirect_to path, notice: "…" No, it is a redirect Yes, then removed
flash[:alert] and render Yes Yes, once more
flash.now[:alert] and render Yes No

With Turbo, keep flash messages inside the page body. Turbo Drive replaces the <body> on every visit and only merges the <head>, and after a form submission it expects a redirect, rendering in place only a response with a 4xx or 5xx status. A failed form therefore renders with status 422 and flash.now, as the program’s form actions do.

Setting flash before render

flash[:alert] = "Title can't be blank" and then render :new shows the alert twice. Use flash.now[:alert] with a render, and plain flash or redirect_to … alert: only with a redirect.

Checkpoint

Check: flash and flash.now

  1. Question 1 of 2 An action sets flash[:alert] and then calls render :new (no redirect). How many pages show the alert in the recorded run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Two: the rendered form, and the next page after it

    flash is for the next request. With render there is no next request yet, so the message shows on the rendered page and again on the following one. flash.now shows it only on the render.

  2. Question 2 of 2 Which pairing shows a message exactly once, on the page the user lands on?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: redirect_to path, notice: "Saved"

    A redirect is a new request, so flash set with it shows on the page the user reaches and is then cleared. Use flash.now with a render, and plain flash only with a redirect.

Current lasts one request, and a job starts it empty

Code deep inside a request often needs to know who is asking: a model that records who created a notice, a log line that names the teacher. Passing the user through every call is tedious, so the generated authentication writes it to Current, a class built on Current attributes: A class built on ActiveSupport::CurrentAttributes that holds per-request context, such as the signed-in user, isolated to the request's thread and emptied by Rails before and after each request and job.:

app/models/current.rb, as the authentication generator writes it Ruby · kaksha/app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :session
  delegate :user, to: :session, allow_nil: true
end

resume_session stores the signed-in row in Current.session, and Current.user reads the user through it. The API documentation describes the class as a thread-isolated singleton that resets automatically before and after each request. Rails empties it when the executor that wraps the request completes, and jobs run inside an executor of their own. Follow one request and the job it enqueues:

Interactive · stage by stage Where Current.user is set, read and emptied Follow Asha's request down the column, then the branch to the job that a worker runs after the request has ended.
Where Current.user is set, read and emptied, step 7 of 7. Showing Request starts (Current.user is nil), before_action (Current.user = Asha), Notice.create! (creator is Asha), Response sent, Executor completes (Current.user is nil again), A worker runs the job (Current.user is nil). In focus: A worker runs the job.perform_laterRequest startsCurrent.user is nilbefore_actionCurrent.user = AshaNotice.create!creator is AshaResponse sentExecutor completesCurrent.user is nil againA worker runs the jobCurrent.user is nil

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The request starts. Asha's POST /notices enters ActionDispatch::Executor, a middleware near the top of the stack. Every Current attribute starts out nil for this request.
  2. A filter sets the user. The before_action finds Asha and assigns Current.user. From here on, any code that runs for this request on this thread can read it, models included.
  3. The model reads it. Notice declares belongs_to :creator with default: -> { Current.user }, so Notice.create! fills creator with Asha without the controller passing her in. The recorded run printed notice 1 by "Asha".
  4. A job is enqueued. The action also calls NoticeMailJob.perform_later(notice). Only the job class and its arguments go into the queue: the notice, as a reference, but not Current.
  5. The response goes out. The action renders its text and the response climbs back up the middleware. Until the executor finishes, Current.user is still Asha.
  6. Rails empties Current. When the executor completes, Rails calls ActiveSupport::CurrentAttributes.clear_all. The recorded run then read Current.user as nil, while the class attribute LastSeen.user still said "Asha".
  7. The worker starts empty. A worker takes the job later, in an executor of its own. The recorded job saw Current.user = nil but notice.creator = "Asha": what a job needs must travel as an argument or be saved on a record.

The program behind the figure sets Current.user in a before_action, lets a model fill creator from it, keeps the same user in a plain class attribute for comparison, and finally runs the request’s job the way a queue worker does:

Current per request, a class attribute that leaks, and a job Ruby · current_demo.rb
# Current attributes in Rails 8.1: a controller sets Current.user once per request, a model reads it when it creates
# a record, and Rails empties it when the response is done. Beside it, a plain class attribute shows what happens to
# per-request data that nothing resets. Last, a job enqueued in the request runs the way a queue worker runs it,
# after the request and outside it.
ENV["DATABASE_URL"] = "sqlite3::memory:" # an in-memory database instead of config/database.yml
require "rails"
require "active_record/railtie"
require "active_job/railtie"
require "action_controller/railtie"
require "action_dispatch/testing/integration"

module Kaksha
  class Application < Rails::Application
    config.load_defaults 8.1
    config.eager_load = false
    config.logger = Logger.new(nil)
    config.secret_key_base = "kaksha-one-file-example-" * 3 # a real app keeps this in its credentials
    config.hosts << "kaksha.example"
    config.active_job.queue_adapter = :test # keep enqueued jobs in a list instead of a queue
    # These requests come from a script, not from a form, so they carry no form token; a real app keeps this check.
    config.action_controller.allow_forgery_protection = false
  end
end
Kaksha::Application.initialize!
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
  create_table(:users) { |t| t.string :name }
  create_table(:notices) { |t| t.string :title; t.references :creator }
end

class Current < ActiveSupport::CurrentAttributes
  attribute :user
end

class LastSeen # per-request data kept in a class attribute, which nothing resets
  cattr_accessor :user
end

class User < ActiveRecord::Base; end

class Notice < ActiveRecord::Base
  belongs_to :creator, class_name: "User", optional: true, default: -> { Current.user }
end

class NoticeMailJob < ActiveJob::Base
  def perform(notice)
    puts "  the job: Current.user = #{Current.user&.name.inspect}, notice.creator = #{notice.creator&.name.inspect}"
  end
end

class NoticesController < ActionController::Base
  before_action do
    Current.user = User.find_by(name: request.headers["X-Teacher"]) # stands in for the signed-in user
    LastSeen.user = Current.user if Current.user
  end

  def create
    notice = Notice.create!(title: params[:title])
    NoticeMailJob.perform_later(notice)
    render plain: "notice #{notice.id} by #{notice.creator&.name.inspect}; LastSeen.user = #{LastSeen.user&.name.inspect}"
  end
end

Kaksha::Application.routes.draw { post "/notices", to: "notices#create" }
User.create!(name: "Asha")

browser = ActionDispatch::Integration::Session.new(Kaksha::Application)
browser.host! "kaksha.example"
adapter = ActiveJob::Base.queue_adapter

browser.post "/notices", params: { title: "Holiday on Friday" }, headers: { "X-Teacher" => "Asha" }
puts "Asha's request: #{browser.response.body}"
puts "  after the response: Current.user = #{Current.user.inspect}, LastSeen.user = #{LastSeen.user&.name.inspect}"

browser.post "/notices", params: { title: "Fees due" } # nobody signed in
puts "A request with nobody signed in: #{browser.response.body}"

puts "A worker runs the job of Asha's request:"
ActiveJob::Base.execute(adapter.enqueued_jobs.first.stringify_keys) # what a queue worker does with a job it takes

Output

Asha's request: notice 1 by "Asha"; LastSeen.user = "Asha"
  after the response: Current.user = nil, LastSeen.user = "Asha"
A request with nobody signed in: notice 2 by nil; LastSeen.user = "Asha"
A worker runs the job of Asha's request:
  the job: Current.user = nil, notice.creator = "Asha"

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby current_demo.rb

Asha’s notice got her as its creator, and once the response was done Current.user was nil again. The class attribute LastSeen.user still said "Asha" during the next request, which nobody had signed in for. A class attribute is one value for the whole process, shared by all three threads Puma runs in a new app, so code that trusted it would have put Asha’s name on someone else’s notice. The job saw an empty Current but found Asha through notice.creator: whatever a job needs must travel as an argument or be saved on a record.

Keeping the signed-in user in a class attribute

cattr_accessor :user, a class variable or a global set in a before_action survives into the next request on any thread. Use an attribute of Current, which Rails empties after every request and job.

The API page adds a warning worth keeping: “Current should only be used for a few, top-level globals, like account, user, and request details.” A value only one controller needs is clearer as an argument.

Key idea

Current holds what one request knows about its context, on its own thread, and Rails empties it when the request or job ends; anything that must outlive the request goes into a record or a job argument.

Checkpoint

Check: Current attributes per request

  1. Question 1 of 2 A before_action sets Current.user to a teacher. What does Current.user read right after the response is sent?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: nil, because Rails empties every Current attribute when the request's executor completes

    Current attributes reset before and after each request. The run read Current.user as nil after the response, while the plain class attribute LastSeen.user still held "Asha" into the next request.

  2. Question 2 of 2 A job enqueued during Asha's request runs later on a worker. In the run, what were Current.user and notice.creator inside the job?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Current.user was nil, notice.creator was "Asha"

    A job starts with an empty Current, so anything it needs must travel as an argument or be saved on a record. The run showed Current.user nil and notice.creator "Asha", read from the saved notice.

Practise: read the cookies before the browser does

These questions replay the lesson’s runs: the 284-character session, the edited cookies, the two force_ssl modes, the sign-out and the leaking class attribute. Each answer explains itself as soon as you pick it:

Practice that adapts to you

Practise this lesson

12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 Where does a new Rails 8.1 app keep the session by default?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: In one encrypted cookie in the browser, named after the app

    The Action Controller guide names the cookie store the default and recommended store. It keeps the encrypted session hash in the browser, so reads need no database query.

  2. Question 2 of 12 Which values are safe to keep in the cookie-store session? Choose all that apply.

    Choose every answer that is right.

    Show the answer: Question 2 of 12

    Answer:

    • The id of the centre a teacher picked
    • The locale the user chose

    The session suits small, replay-safe values. Counts that can be spent and large blobs do not belong there: the run restored a spent count from an old cookie and raised CookieOverflow on 5,000 characters.

  3. Question 3 of 12 The run wrote cookies[:theme] = "dark", and the browser sent it back as "light". What did the app read?
    Show the answer: Question 3 of 12

    Answer: "light", because a plain cookie can be changed and the app trusts whatever comes back

    A plain cookie carries no signature, so any edit is accepted. Use cookies.signed when a value must not be changed, and cookies.encrypted when it must also stay hidden.

  4. Question 4 of 12 Why can the app trust a signed or encrypted cookie that came from the browser?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: Its signature or encryption uses a key derived from secret_key_base, which the browser never sees

    Both protected jars derive their keys from secret_key_base, kept in credentials. Anyone with that secret can forge cookies the app trusts, and changing it makes every existing session and protected cookie unreadable.

  5. Question 5 of 12 What does SameSite=Lax, the default Rails writes, do to a cookie?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Leaves it off cross-site requests, except top-level navigations with a safe method such as GET

    SameSite=Lax keeps a cookie off cross-site requests apart from top-level safe navigations. OWASP treats it as defence in depth, and Rails still checks a CSRF token on requests that change data.

  6. Question 6 of 12 In the run, fifteen days later a copy of the cookie written with expires 14.days read as nil, while the permanent copy read 41. Why?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: Rails signs the expiry into the cookie, so the 14-day one had expired on the server's side while the 20-year one had not

    Rails writes the expiry into the signed cookie and checks it on read, so a copy sent after its 14 days reads as nil. The permanent copy's 20-year expiry had not passed, so it still read 41.

  7. Question 7 of 12 What does the signed session_id cookie that the generated authentication writes actually hold?
    Show the answer: Question 7 of 12

    Answer: The id of a row in the sessions table

    The generated code keeps sign-in state in a sessions row and puts only that row's id in the signed session_id cookie, so destroying the row on sign-out makes an old cookie name nothing.

  8. Question 8 of 12 A teammate logs users out only with cookies.delete(:session_id). Why is that not enough?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: A copy of the cookie made earlier still names a valid row, so it keeps working

    OWASP warns that deleting a cookie does not invalidate a stolen copy. The generated terminate_session destroys the sessions row as well, so every copy of the cookie stops working at once.

  9. Question 9 of 12 A form fails validation in a Turbo app, and the action re-renders the form. Which pairing shows the error correctly?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: flash.now[:alert] with a 422 status, the error inside the page body

    Turbo replaces the body only on a redirect or a 4xx/5xx render, so a failed form answers with 422 and flash.now, with the messages inside the body that Turbo swaps in.

  10. Question 10 of 12 An action sets a notice and redirects to a page that itself reads the flash and redirects again. How do you keep the notice for the final page?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Call flash.keep in the middle action

    The Action Controller guide uses flash.keep for exactly this: when an intermediate request would otherwise sweep the flash, keep carries it one request further to the page that displays it.

  11. Question 11 of 12 Which value is a good fit for a Current attribute?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: The signed-in user, needed by code all over the request

    Current suits a few top-level, per-request globals such as the user. Controller-specific values are clearer as arguments, and anything that must outlive the request belongs on a record.

  12. Question 12 of 12 Why does keeping the signed-in user in cattr_accessor :user put one teacher's name on another's request?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: A class attribute is one value for the whole process, shared by every thread Puma runs

    A class attribute is a single value for the whole process, shared by Puma's threads (3 in a new app). Current is per-request and thread-isolated and is emptied after each request, so it does not leak between teachers.

Change the lifetime and run it again

With Rails 8.1 installed, run ruby cookie_flags.rb, then change travel 15.days to travel 13.days. The 14-day copy now reads as 41, because its signed expiry has not passed yet.

Interview questions

Warm-up: how do the session, cookies.signed and cookies.encrypted differ in a Rails 8.1 app?

All three end up in cookies, and they differ in what the user can do with them. A signed cookie carries its value in Base64 next to a signature made with a key from secret_key_base, so a user can read it but any change makes it read as nil. An encrypted cookie is unreadable as well as tamper-proof. The session is a hash that the default cookie store keeps in one encrypted cookie, _kaksha_session for an app named Kaksha, with HttpOnly and SameSite=Lax; it is limited to 4,096 bytes and, being a cookie, can be replayed from a copy. So ids the user may see go in signed cookies, private values in encrypted ones, small per-visit state in the session, and anything with value in the database.

The bank of this module takes the same runs further, into three cases an interviewer may build on them: the choice of a session store, signing a teacher out everywhere, and sharing a sign-in across subdomains.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    Kaksha must sign a teacher out of every device at once, and a review flags that session data can be replayed from a copied cookie. Would you keep the default cookie store or move the session server-side? Defend the choice.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    On the generated authentication, add idle and absolute session timeouts and a "log out of all devices" button. What do you change, and what timeout values do you pick for a coaching app that handles fee payments?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Where does the default session store keep a session, and what limits follow from that?

    In one encrypted cookie in the browser. Rails can trust what comes back but can never take back a copy it sent, and a session over 4,096 bytes raises CookieOverflow, so keep only small values that are safe to replay.

  2. What can a user do with a plain, a signed and an encrypted cookie?

    Read and change a plain one, read but not change a signed one, and neither read nor change an encrypted one. Both protected jars depend on secret_key_base staying private.

  3. When do Rails cookies get the Secure flag, and how long does permanent ask for?

    Secure comes with config.force_ssl, which also redirects HTTP and sends HSTS. permanent asks for 20 years, while browsers that follow the cookie draft keep a cookie 400 days at most, so a lifetime in days with checks on the server is safer.

  4. How does the generated authentication make signing out stop an old copy of the cookie?

    The signed cookie only names a row of the sessions table. Signing out destroys the row, so a copied cookie then names nothing. Code that keeps a user id in the session instead calls reset_session when privileges change.

  5. When do you set flash, and when flash.now?

    flash lasts until the next request, so it suits a redirect; flash.now shows the message on the current render only. With Turbo, a failed form answers 422 with its message in the body.

  6. Why does a background job not see the Current.user of the request that enqueued it?

    Current holds one request's context on its own thread, and Rails empties it when each request and each job ends. Give the job what it needs as an argument, or let it read a record.

Key takeaways

  • The default session store keeps the whole session in one encrypted cookie that Rails can trust but never recall, so it suits small, replay-safe values and refuses anything over 4,096 bytes with CookieOverflow.
  • Plain cookies can be read and changed by the user, signed cookies can be read but not changed, and encrypted cookies can be neither read nor changed; every protected jar depends on keeping secret_key_base secret.
  • Cookies get Secure only when config.force_ssl is on, which also redirects HTTP and sends HSTS, and permanent means 20 years where browsers following the cookie draft keep 400 days at most, so a lifetime in days with server checks is the safer choice.
  • The generated authentication keeps sign-in state in a sessions row named by a signed cookie, so signing out destroys the row and an old copy of the cookie stops working; code that keeps a user id in the session calls reset_session when privileges change.
  • The flash lasts until the next request, which suits a redirect, while flash.now serves a render; with Turbo the messages belong in the body and a failed form answers with 422.
  • Current holds per-request context on its own thread and is emptied after every request and job, unlike a class attribute, so a job receives what it needs as an argument or from a record.

Terms in this lesson

Current attributes
A class built on ActiveSupport::CurrentAttributes that holds per-request context, such as the signed-in user, isolated to the request's thread and emptied by Rails before and after each request and job.
encrypted cookie
A cookie written with cookies.encrypted: its value is both encrypted and signed with keys from secret_key_base, so the user can neither read it nor change it without it reading as nil.
flash
A part of the session for a short message that one action sets and the very next request can read, after which Rails removes it; flash.now makes the message available on the current render only.
session store
Where Rails keeps a request's session. The default cookie store holds the whole session, encrypted, in one cookie in the browser; other stores keep it server-side with only an id in the cookie.
signed cookie
A cookie written with cookies.signed: its value is readable but carries a signature made from secret_key_base, so any change the user makes to it is detected and the value reads as nil.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.