Rails session expiry: the cookie store, the Session row, and the timeout neither gives you
September 13, 2026
Rails session expiration is two questions wearing one name. Ask how to expire a session in Rails 8
and the honest answer starts with a question back: which session? There are two, they are unrelated,
and they fail in opposite directions. One holds no server-side state and so has nothing to expire.
The other holds a database row that expires never.
The authentication hub covers what the framework gives
you on the way in. This is what happens afterwards, and it is the part the generator is quietest
about.
The two things called the session
The first is the session hash you write to in a controller. It is backed by a session store, and
the Rails default is ActionDispatch::Session::CookieStore.
The second arrives when you run the Rails 8 authentication generator. It creates a Session model,
a sessions table, and an Authentication concern that looks a signed-in user up from a cookie.
They share a word and nothing else. The first is a place to put small values. The second is the
record that says a person is signed in. Almost every confused answer about session expiry comes
from an answer about one being given to a question about the other.
The whole session lives inside the cookie. Action Pack's own source describes it plainly: cookies
are encrypted using the application's secret_key_base, and encrypted cookies go a step further
than signed ones in that their contents cannot be read by the client at all.
That design has one consequence worth stating before any talk of expiry: there is no server-side
record of the session, so there is nothing on the server that could be expired, revoked or listed.
The only thing that invalidates every cookie session at once is rotating secret_key_base, which
signs every user out of every device simultaneously and is not a session management feature.
The second consequence is size. The store is capped:
ActionDispatch::Cookies::MAX_COOKIE_SIZE# => 4096
Go past 4096 bytes and Rails raises ActionDispatch::Cookies::CookieOverflow. It does not truncate
and it does not warn: the request fails. Cookies are also serialised as JSON by default
(config.action_dispatch.cookies_serializer is :json), so a value goes in as a Ruby object and
comes back as its JSON shape, which is how a symbol key becomes a string on the next request.
expire_after sets the expiry attribute of the browser cookie. That is the whole of it. The browser
is asked to stop sending the cookie after two weeks, and a well behaved browser complies.
It is not a server-side timeout, and the difference matters the moment you think about the case
session expiry exists for. A cookie value that was copied out of a browser carries no expiry with
it. Replayed by something that is not a browser, it is still a valid encrypted payload signed by
your secret_key_base, and the server, holding no record of anything, has no way to know it should
have stopped working last Tuesday.
So expire_after is a tidiness feature and a mild convenience. Treat it as a security control and
you have misread the name.
The generated Session record is the one that signs people in
The Rails 8 authentication generator takes the other road. Here is the real lookup:
Only the row id travels in the cookie, signed so it cannot be edited. Everything that says who this
person is lives in the database, which is exactly what the cookie store could not give you: a
server-side record you can look at, list, and destroy.
Read the two words in that assignment, though.
permanent is an Action Dispatch cookie jar that sets the expiry to twenty years from now. Not a
session cookie that dies with the browser window, not two weeks: twenty years.
And the table underneath has no expiry to compensate. This is the schema the generator produces,
unchanged:
user_id, two forensic columns, timestamps. No expires_at. No last_active_at that anything
reads.
A session created by the generated Rails 8 authentication never expires. Not after a day, not
after a year. It ends when the row is deleted or when the cookie is lost, and nothing in the
framework deletes the row for you. That is a defensible default for a product people stay signed
into, and it is not what most people assume they got.
Adding a Rails session timeout, and what it costs
The naive version is three lines and one of them is expensive:
with the lookup narrowed to Session.live.find_by(id: ...).
That works, and it is honest about what it measures: updated_at moves only when something writes
to the row. Left alone, it is the sign-in time, so this is an absolute expiry, two weeks from
sign-in, not the idle timeout people usually mean.
Turning it into an idle timeout means touching the row on use, and that is where the bill arrives: a
database write on every authenticated request, on a table every request already reads. On a busy
app that is the single hottest write in the system, and it exists to move a timestamp by a few
seconds.
The usual answer is to touch it only when the value is meaningfully stale:
Now the write happens at most four times an hour per session, and the timeout is accurate to fifteen
minutes, which is the precision an idle timeout actually needs. The trade is explicit rather than
accidental, which is the point.
Signing out has two halves
cookies.delete(:session_id)
Deleting the cookie ends the session for that browser. It does not end the session.
The row is still there, still find_by-able, and any other copy of that cookie value still resolves
to a signed-in user. If the reason someone is signing out is that they were on a shared machine or
think they were compromised, deleting only the cookie has given them a feeling rather than a
logout. The row has to go too, and because the row is the thing, destroying it is also what lets you
build "sign out everywhere": user.sessions.destroy_all.
This is the capability the cookie store cannot offer at any price, and it is the strongest argument
for the generator's design. The cost of a database read per request buys a session you can revoke.
The three flags on the cookie
httponly: true keeps the cookie out of document.cookie, so a cross-site scripting bug cannot
read it out. It is in the generated code and should stay there.
same_site: :lax stops the cookie riding along on cross-site POSTs, which is CSRF defence in depth
underneath the token check. :lax rather than :strict because :strict also drops the cookie on
an ordinary inbound link, so a user arriving from an email lands signed out. That surprise is why
:lax is the common choice, and the Rails default for the cookie session store is computed rather
than fixed, so it is worth setting deliberately rather than inheriting.
secure: true refuses to send the cookie over plain HTTP. In production there is no argument
against it. The generated code leaves it off because development is not HTTPS, which means adding it
is on you.
Which one to reach for
If you need to know who is signed in, the Session row is the answer, and the generator already
gave it to you. Treat the cookie store as what it is: a small, temporary, 4KB scratchpad for things
like a redirect target or a flash-adjacent value, with no expiry story worth building on.
If you need sessions that end, add the column or the scope and decide consciously between absolute
and idle. The framework will not decide it for you, and the default is "never", which no security
review has ever accepted without being told.
The same reasoning runs through the rest of this hub.
has_secure_password covers the
primitive that authenticates the person in the first place, and
Rails 8 authentication vs Devise
weighs the generated stack against the gem that would have made these decisions for you. For an API
with no cookies at all,
Rails JWT API authentication
is where the expiry question moves into the token itself.
Rails session expiration is two questions wearing one name. Ask how to expire a session in Rails 8 and the honest answer starts with a question back: which session? There are two, they are unrelated, and they fail in opposite directions. One holds no server-side state and so has nothing to expire. The other holds a database row that expires never.
The authentication hub covers what the framework gives you on the way in. This is what happens afterwards, and it is the part the generator is quietest about.
The two things called the session
The first is the
sessionhash you write to in a controller. It is backed by a session store, and the Rails default isActionDispatch::Session::CookieStore.The second arrives when you run the Rails 8 authentication generator. It creates a
Sessionmodel, asessionstable, and anAuthenticationconcern that looks a signed-in user up from a cookie.They share a word and nothing else. The first is a place to put small values. The second is the record that says a person is signed in. Almost every confused answer about session expiry comes from an answer about one being given to a question about the other.
The cookie store keeps nothing on the server
The whole session lives inside the cookie. Action Pack's own source describes it plainly: cookies are encrypted using the application's
secret_key_base, and encrypted cookies go a step further than signed ones in that their contents cannot be read by the client at all.That design has one consequence worth stating before any talk of expiry: there is no server-side record of the session, so there is nothing on the server that could be expired, revoked or listed. The only thing that invalidates every cookie session at once is rotating
secret_key_base, which signs every user out of every device simultaneously and is not a session management feature.The second consequence is size. The store is capped:
Go past 4096 bytes and Rails raises
ActionDispatch::Cookies::CookieOverflow. It does not truncate and it does not warn: the request fails. Cookies are also serialised as JSON by default (config.action_dispatch.cookies_serializeris:json), so a value goes in as a Ruby object and comes back as its JSON shape, which is how a symbol key becomes a string on the next request.What expire_after actually does
expire_aftersets the expiry attribute of the browser cookie. That is the whole of it. The browser is asked to stop sending the cookie after two weeks, and a well behaved browser complies.It is not a server-side timeout, and the difference matters the moment you think about the case session expiry exists for. A cookie value that was copied out of a browser carries no expiry with it. Replayed by something that is not a browser, it is still a valid encrypted payload signed by your
secret_key_base, and the server, holding no record of anything, has no way to know it should have stopped working last Tuesday.So
expire_afteris a tidiness feature and a mild convenience. Treat it as a security control and you have misread the name.The generated Session record is the one that signs people in
The Rails 8 authentication generator takes the other road. Here is the real lookup:
and the real write, at sign-in:
Only the row id travels in the cookie, signed so it cannot be edited. Everything that says who this person is lives in the database, which is exactly what the cookie store could not give you: a server-side record you can look at, list, and destroy.
Read the two words in that assignment, though.
permanentis an Action Dispatch cookie jar that sets the expiry to twenty years from now. Not a session cookie that dies with the browser window, not two weeks: twenty years.And the table underneath has no expiry to compensate. This is the schema the generator produces, unchanged:
user_id, two forensic columns, timestamps. Noexpires_at. Nolast_active_atthat anything reads.A session created by the generated Rails 8 authentication never expires. Not after a day, not after a year. It ends when the row is deleted or when the cookie is lost, and nothing in the framework deletes the row for you. That is a defensible default for a product people stay signed into, and it is not what most people assume they got.
Adding a Rails session timeout, and what it costs
The naive version is three lines and one of them is expensive:
with the lookup narrowed to
Session.live.find_by(id: ...).That works, and it is honest about what it measures:
updated_atmoves only when something writes to the row. Left alone, it is the sign-in time, so this is an absolute expiry, two weeks from sign-in, not the idle timeout people usually mean.Turning it into an idle timeout means touching the row on use, and that is where the bill arrives: a database write on every authenticated request, on a table every request already reads. On a busy app that is the single hottest write in the system, and it exists to move a timestamp by a few seconds.
The usual answer is to touch it only when the value is meaningfully stale:
Now the write happens at most four times an hour per session, and the timeout is accurate to fifteen minutes, which is the precision an idle timeout actually needs. The trade is explicit rather than accidental, which is the point.
Signing out has two halves
Deleting the cookie ends the session for that browser. It does not end the session.
The row is still there, still
find_by-able, and any other copy of that cookie value still resolves to a signed-in user. If the reason someone is signing out is that they were on a shared machine or think they were compromised, deleting only the cookie has given them a feeling rather than a logout. The row has to go too, and because the row is the thing, destroying it is also what lets you build "sign out everywhere":user.sessions.destroy_all.This is the capability the cookie store cannot offer at any price, and it is the strongest argument for the generator's design. The cost of a database read per request buys a session you can revoke.
The three flags on the cookie
httponly: truekeeps the cookie out ofdocument.cookie, so a cross-site scripting bug cannot read it out. It is in the generated code and should stay there.same_site: :laxstops the cookie riding along on cross-site POSTs, which is CSRF defence in depth underneath the token check.:laxrather than:strictbecause:strictalso drops the cookie on an ordinary inbound link, so a user arriving from an email lands signed out. That surprise is why:laxis the common choice, and the Rails default for the cookie session store is computed rather than fixed, so it is worth setting deliberately rather than inheriting.secure: truerefuses to send the cookie over plain HTTP. In production there is no argument against it. The generated code leaves it off because development is not HTTPS, which means adding it is on you.Which one to reach for
If you need to know who is signed in, the
Sessionrow is the answer, and the generator already gave it to you. Treat the cookie store as what it is: a small, temporary, 4KB scratchpad for things like a redirect target or a flash-adjacent value, with no expiry story worth building on.If you need sessions that end, add the column or the scope and decide consciously between absolute and idle. The framework will not decide it for you, and the default is "never", which no security review has ever accepted without being told.
The same reasoning runs through the rest of this hub. has_secure_password covers the primitive that authenticates the person in the first place, and Rails 8 authentication vs Devise weighs the generated stack against the gem that would have made these decisions for you. For an API with no cookies at all, Rails JWT API authentication is where the expiry question moves into the token itself.