Setting a cookie in Rails
There are four cookie jars in Rails and they do not share a size check. That turns out to be the most consequential thing about them, because the one jar that most people reach for first is the one with no guard on it, and the two that have a guard measure the wrong value.
Everything below was measured on 2026-10-03 against actionpack 8.1.4, Rack 3.2.7 and Ruby 4.0.5,
in an application generated by rails new --minimal, through ActionDispatch::IntegrationTest.
Signing costs 3x and encryption costs a bit more
A controller that writes the same 100 byte string through each jar:
def set
payload = "x" * params[:bytes].to_i
jar = { "plain" => cookies, "signed" => cookies.signed, "encrypted" => cookies.encrypted }[params[:kind]]
jar[:probe] = payload
end
and the value as it appears in the set-cookie header:
| Jar | Bytes on the wire for a 100 byte payload |
|---|---|
cookies |
100 |
cookies.signed |
306 |
cookies.encrypted |
328 |
The markup is not proportional, it is mostly fixed: a signature and, for the encrypted jar, an IV and an auth tag, all base64 encoded. So it hurts small values and barely registers on large ones. Storing a user id in a signed cookie is a 300 byte cookie.
Only two of the four jars check the size
MAX_COOKIE_SIZE is 4096 and the check lives in a module both serialized jars include:
def check_for_overflow!(name, options)
total_size = name.to_s.bytesize + options[:value].bytesize
if total_size > MAX_COOKIE_SIZE
raise CookieOverflow, "#{name} cookie overflowed with size #{total_size} bytes"
end
end
Grep for the call sites in actionpack-8.1.4/lib/action_dispatch/middleware/cookies.rb and there
are two, at lines 646 and 698. Line 646 is SignedKeyRotatingCookieJar#commit. Line 698 is
EncryptedKeyRotatingCookieJar#commit. AbstractCookieJar#commit is def commit(name, options); end,
an empty method, and the plain CookieJar inherits that.
Measured consequence:
get "/set/plain/8000"
assert_response :success # passes
# set-cookie header: 8028 bytes
No exception, no warning, no log line. Rails emits the header and the browser throws it away, because RFC 6265 only asks a user agent to store at least 4096 bytes per cookie. The failure is entirely silent and it happens on the client, which is the worst place for it.
Where each guarded jar stops:
| Jar | Largest payload accepted | Payload that raises | Message |
|---|---|---|---|
cookies.signed |
2230 | 2231 | probe cookie overflowed with size 4099 bytes |
cookies.encrypted |
2230 | 2231 | probe cookie overflowed with size 4101 bytes |
Same cliff, two bytes apart in what they report, which is the packing difference showing up.
The guard measures the wrong value
This is the part worth knowing, and it took a second measurement to see.
check_for_overflow! runs inside commit, on options[:value], which is the serialized and
signed or encrypted string. The cookie value that reaches the browser is that string percent
escaped, and base64 contains three characters that get escaped.
For the largest encrypted payload Rails accepts:
encrypted at the largest accepted payload: wire bytes 4326
... after unescaping 4088
... Rails checked this against 4096
4088 passes. 4326 is what the browser is offered. Rails enforced a 4096 byte limit against a number that is 238 bytes smaller than the thing it was limiting.
And it is not a fixed offset. Ciphertext is random, so how many + and / bytes it contains
changes per request, and each one becomes three bytes. The same payload, thirty times:
encrypted, 2230 byte payload, 30 runs: min 4310 max 4394 spread 84
how many of the 30 exceeded 4096 on the wire: 30
Thirty out of thirty. A cookie this size is not occasionally too big, it is always too big, and Rails will never tell you.
The escaped characters, counted on a small value so they are readable:
| Jar | Escapes in a 100 byte payload's value |
|---|---|
cookies.signed |
{"%3D" => 2} |
cookies.encrypted |
{"%2B" => 2, "%2F" => 6, "%3D" => 4} |
The signed jar only escapes its base64 padding, which is why its numbers look sane. The encrypted jar escapes padding plus whatever the ciphertext happens to contain.
The practical rule that falls out: stay under about 2000 bytes of payload in an encrypted cookie
and do not treat CookieOverflow as the thing that will stop you. Put it in the database and keep
a key in the cookie.
cookies.delete does nothing more often than you would expect
def delete(name, options = {})
return unless @cookies.has_key? name.to_s
# ...
end
The guard is the first line. If the incoming request did not carry that cookie, delete returns
nil and writes no deletion header.
Measured in two steps:
step 1, set with path=/admin scoped=1; path=/admin; samesite=lax
step 2, delete from path=/ (none)
A browser does not send a path=/admin cookie to /, so on that request the cookie is not in the
jar, so delete short circuits, so no Set-Cookie goes back, so the cookie survives. Nothing
anywhere reports a problem.
The fix is the documented one, cookies.delete(:scoped, path: "/admin"), and the reason it is
needed is worth separating into two independent facts, because people usually know one of them:
deleting a cookie requires the same path and domain it was written with, and Rails will not
even try unless the cookie came in on this request.
The session cookie, attribute by attribute
_lab_session=<redacted>; path=/; httponly; samesite=lax
httponly and path=/ are defaults of the store. No secure, because the measurement ran over
http; in production behind TLS, config.force_ssl is what adds it.
samesite=lax is the interesting one, because of how it gets there. In
action_dispatch/middleware/session/cookie_store.rb:
DEFAULT_SAME_SITE = proc { |request| request.cookies_same_site_protection } # :nodoc:
def initialize(app, options = {})
options[:cookie_only] = true
options[:same_site] = DEFAULT_SAME_SITE if !options.key?(:same_site)
super
end
It is a proc, evaluated per request, not a value read at boot. So
config.action_dispatch.cookies_same_site_protection can itself be a lambda that inspects the
request, and the session cookie follows it. Inspecting the session options at runtime shows the
proc sitting there rather than a symbol:
{key: "_lab_session", cookie_only: true, same_site: #<Proc:0x... cookie_store.rb:62>}
lax is what makes a cross site POST arrive with no session, which is the single most common cause
of Can't verify CSRF token authenticity.
cookies.permanent is literal
forever=1; path=/; expires=Wed, 03 Oct 2046 07:50:38 GMT; samesite=lax
Measured on 2026-10-03. Twenty years, to the second. It is expires: 20.years.from_now, which also
means the expiry is computed at write time rather than being a rolling window: the cookie does not
renew itself on later visits unless you write it again.
The dead end: reading the header inside the action
The first version of every probe above tried to read the header in the controller:
cookies.permanent[:forever] = "1"
render plain: response.headers["Set-Cookie"].to_s # => ""
Empty. The cookie jar is committed by ActionDispatch::Cookies on the way out, after the action
has returned, so during the action there is nothing in that header yet.
In a test, after the response, there is, and under Rack 3 the key has been downcased:
response.headers.to_h.keys
# => ["x-frame-options", ..., "set-cookie", "x-request-id", "x-runtime", "content-length"]
response.headers["Set-Cookie"] happens to work in the integration test because that object is
case insensitive on read. Inside the action it is not the same object and the capitalised key
returns nothing. Both facts cost a confusing half hour, and the honest way to read a cookie back in
a test is response.cookies, which answered {"forever" => "1"}.
What this page does not cover
No browser. Every measurement came from ActionDispatch::IntegrationTest, so the 4096 byte client
side limit is quoted from RFC 6265 rather than observed, and nothing here demonstrates what Chrome
or Safari actually does with the 8028 byte header they were offered. Per browser total cookie
counts and per domain byte budgets were not touched at all, and they are the other half of this
problem on a site with many cookies.
cookies.signed_or_encrypted, key rotation through
config.action_dispatch.cookies_rotations, and the use_cookies_with_metadata purpose and expiry
embedding were all read in the source and never exercised. The session was only ever the default
CookieStore; ActiveRecord::SessionStore and cache backed sessions have none of the size problems
above and a different set of their own.
Comments
No comments yet. Be the first.