Why is rails constantly sending back a Set-Cookie header?

See the answer by @georgebrock on why this happens. It's pretty easy to patch rails to change this behaviour to only set the cookie if the session changes. Just drop this code in the initializers directory.

require 'rack/session/abstract/id' # defeat autoloading
module ActionDispatch
  class Request
    class Session # :nodoc:
      def changed?;@changed;end
      def load_for_write!
        load! unless loaded?
        @changed = true
      end
    end
  end
end

module Rack
  module Session
    module Abstract
      class Persisted
        private
        def commit_session?(req, session, options)
          if options[:skip]
            false
          else
            has_session = session.changed? || forced_session_update?(session, options)
            has_session && security_matches?(req, options)
          end
        end
      end
    end
  end
end

Summary: Set-Cookie headers are set on almost every response, because

  1. the default session store will try to write the session data to an encrypted cookie on any request that has accessed the session (either to read from it or write to it),
  2. the encrypted value changes even when the plain text value hasn't,
  3. the encryption happens before it reaches the code that's responsible for checking if a cookie value has changed to avoid redundant Set-Cookie headers.

Plain-text cookies

In Rails, the ActionDispatch::Cookies middleware is responsible for writing Set-Cookie response headers based on the contents of a ActionDispatch::Cookies::CookieJar.

The normal behaviour is what you'd expect: if a cookie's value hasn't changed from what was in the request's Cookie header, and the expiry date isn't being updated, then Rails won't send a new Set-Cookie header in the response.

This is taken care of by a conditional in CookieJar#[]= which compares the value already stored in the cookie jar against the new value that's being written.

Encrypted cookies

To handle encrypted cookies, Rails provides an ActionDispatch::Cookies::EncryptedCookieJar class.

The EncryptedCookieJar relies on ActiveSupport::MessageEncryptor to provide the encryption and decryption, which uses a random initialisation vector every time it's called. This means it's almost guaranteed to return a different encrypted string even when it's given the same plain text string. In other words, if I decrypt my session data, and then re-encrypt it, I'll end up with a different string to the one I started with.

The EncryptedCookieJar doesn't do very much: it wraps a regular CookieJar, and just provides encryption as data goes in, and decryption as data comes back out. This means that the CookieJar#[]= method is still responsible for checking if a cookie's value has changed, and it doesn't even know the value it's been given is encrypted.

These two properties of the EncryptedCookieJar explain why setting an encrypted cookie without changing its value will always result in a Set-Cookie header.

The session store

Rails provides different session stores. Most of them store the session data on a server (e.g. in memcached), but the default— ActionDispatch::Session::CookieStore—uses EncryptedCookieJar to store all of the data in an encrypted cookie.

ActionDispatch::Session::CookieStore inherits a #commit_session? method from Rack::Session::Abstract::Persisted, which determines if the cookie should be set. If the session's been loaded, then the answer is pretty much always “yes, set the cookie”.

As we've already seen, in the cases where the session's been loaded but not changed we're still going to end up with a different encrypted value, and therefore a Set-Cookie header.