LaunchKit
← All posts
· 8 min read · by The LaunchKit team · 11 views

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.

One encrypted cookie followed through three stages with its byte count at each. A 2230 byte payload becomes 4088 bytes once serialised, encrypted and base64 encoded, and that is the stage where Rails compares it against MAX_COOKIE_SIZE of 4096 and lets it through. The value is then percent escaped on its way into the header, which takes it to 4326 bytes, and nothing checks it again. Thirty runs of the identical payload landed between 4310 and 4394 bytes, all thirty over the limit Rails believed it had enforced.

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.

_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.

#rails #http #security

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.