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.
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.
The session is one encrypted cookie the browser carries
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:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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 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
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.
# 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
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:
# 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:
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
Sign-in state lives in a signed cookie and a database row
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:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- 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.
- 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.
- 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."
- 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.
- 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, /.
- 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.
- 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.
- 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:
# 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:
# 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
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 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
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.:
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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".
- 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.
- 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.
- 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".
- 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 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
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.
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.
-
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.
-
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?
-
Senior to staff
Kaksha moves from kaksha.example to a marketing site at www.kaksha.example and an app at app.kaksha.example, and sign-in must be shared across both. How do you scope the session and session_id cookies, and what are the risks?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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_basesecret. - Cookies get Secure only when
config.force_sslis on, which also redirects HTTP and sends HSTS, andpermanentmeans 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
sessionsrow 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 callsreset_sessionwhen privileges change. - The flash lasts until the next request, which suits a redirect, while
flash.nowserves a render; with Turbo the messages belong in the body and a failed form answers with 422. Currentholds 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
- Action Controller Overview (Ruby on Rails)
- Rails 8.1.4 app generator: config/environments/production.rb template (Ruby on Rails)
- Securing Rails Applications (Ruby on Rails)
- Configuring Rails Applications (Ruby on Rails)
- ActionDispatch::Cookies (Rails 8.1.4 API) (Ruby on Rails)
- ActionDispatch::Session::CookieStore (Rails 8.1.4 API) (Ruby on Rails)
- ActionDispatch::Flash::FlashHash (Rails 8.1.4 API) (Ruby on Rails)
- ActiveSupport::CurrentAttributes (Rails 8.1.4 API) (Ruby on Rails)
- Ruby on Rails 8.0 release notes (Ruby on Rails)
- Session Management Cheat Sheet (OWASP Foundation)
- Cookies: HTTP State Management Mechanism (draft-ietf-httpbis-rfc6265bis-22) (IETF)
- Turbo Handbook: Navigate with Turbo Drive (37signals)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress