Session lifecycles for long-running agents: cookies, tokens, expiry
most agent failures I get paged for are not model failures. the model reasoned fine, the plan was fine, and then at hour six the run hit a 401, the login page, or a silent redirect, and everything after that was garbage. the agent kept going because nothing told it that the thing it was authenticated as had stopped existing.
a short demo run hides this completely. you log in, do the task, exit, and the session never has time to expire. a long-running agent is different. it outlives access tokens, idle timeouts, absolute timeouts, cookie rotation, and sometimes the user’s own decision to change a password. the session is a piece of state with a lifecycle, and if you do not model that lifecycle explicitly, the web will model it for you at the worst moment.
the stakes are practical. a run that dies on expiry wastes the tokens and browser time already spent on it. worse, a run that half-works can repeat actions, write partial results, or trigger a security lockout on the account it is acting for. this article is the mechanism-level view of how sessions are born, renewed and killed, and how I structure agents so expiry is a handled event rather than an incident. it assumes you already know what a cookie and an OAuth token are. I am writing from the operator side, running agents out of Singapore, and I will say where I am relying on documentation versus my own scars.
background and prior art
the cookie model is older than most of the tooling around it. RFC 6265 defines Set-Cookie, the Expires and Max-Age attributes, and the rule that Max-Age wins when both are present. a cookie with neither is a “session cookie”, which the spec says lasts until the user agent decides the session ended. that definition is vaguer than people assume. browsers with session restore can keep session cookies across a restart, and a headless automation context may drop them the instant the context closes. so “session cookie” tells you nothing about wall-clock lifetime. the server side is where the real lifetime lives, and the cookie is only a pointer to it. the practical reference for attributes like Secure, HttpOnly, SameSite and the lifetime caps browsers apply is the MDN Set-Cookie page. as of October 2026, Chrome caps cookie lifetime at 400 days regardless of what the server asks for, so a cookie “valid for ten years” is not.
the token side comes from OAuth 2.0, RFC 6749. short-lived access token, longer-lived refresh token, and an invalid_grant error when the refresh token is no longer acceptable. the later hardening guidance, RFC 9700 (the OAuth 2.0 security best current practice), pushes toward refresh token rotation or sender-constrained tokens, and says that if a rotated-out refresh token is replayed, the server should treat it as a sign of theft. that last point matters a lot to agents, because a crashed-and-restarted agent replaying an old refresh token looks exactly like an attacker.
what the prior art does not cover is the agent as a client. these specs assume one client instance holding one credential, restarting rarely, with a human nearby when things go wrong. a fleet of workers, a retrying job queue, and a browser profile cloned across machines all violate those assumptions. most of the pain below comes from that mismatch.
the core mechanism
I split every session into four timers and one identity, and I track all of them explicitly.
- idle timeout: the server drops the session if it sees no activity for N minutes. activity usually means any authenticated request, but sometimes only specific ones.
- absolute timeout: the session ends N hours after creation no matter how active it is. keepalives do not help.
- credential expiry: the cookie’s
Expires/Max-Age, or the token’sexpires_in. this is the client-visible lifetime and often the least important one. - rotation: the server issues a new cookie value or a new refresh token on use, and invalidates the old one.
- identity binding: the session may be bound to something about the client, such as IP range, user agent, device fingerprint, or a key pair.
an agent needs to know, per target, which of these exist and what their values are. you cannot always read them from the wire. idle and absolute timeouts are server policy and are often undocumented for consumer sites. for those I find them by observation on my own accounts, record them in config, and treat them as guesses with an expiry date. for first-party APIs and vendor OAuth, the documentation usually states them, and I use the documented number.
the access token loop
for OAuth-style APIs the loop is simple and worth writing down exactly:
import time, threading
class TokenSession:
def __init__(self, store, refresh_fn, skew=300):
self.store = store # durable, shared, locked
self.refresh_fn = refresh_fn
self.skew = skew # refresh this many seconds early
self._mu = threading.Lock()
def bearer(self):
tok = self.store.read()
if tok["expires_at"] - time.time() > self.skew:
return tok["access_token"]
return self._refresh()
def _refresh(self):
with self._mu, self.store.lock(): # in-process AND cross-process
tok = self.store.read() # re-read inside the lock
if tok["expires_at"] - time.time() > self.skew:
return tok["access_token"] # someone else already did it
new = self.refresh_fn(tok["refresh_token"])
if "refresh_token" not in new: # not every server rotates
new["refresh_token"] = tok["refresh_token"]
new["expires_at"] = time.time() + new["expires_in"]
self.store.write(new) # write BEFORE returning
return new["access_token"]
three details carry most of the value. refresh early by a skew, because clocks drift and requests take time. re-read inside the lock, so a second worker that waited on the lock does not spend a refresh token that was already rotated. and persist the new refresh token before using the new access token, because if the process dies between receiving a rotated token and saving it, you have burned your only valid credential.
the cookie session loop
cookie sessions need the opposite approach. you usually cannot refresh them by calling an endpoint. you renew them by using them, and by capturing whatever Set-Cookie the server sends back. in Playwright the persistence primitive is storageState, which serialises cookies and localStorage to JSON. it does not include sessionStorage, which has bitten me, since some single-page apps keep a CSRF or tab token there.
# after a successful action, snapshot state so a restart resumes the session
await context.storage_state(path=f"state/{account_id}.json")
# on startup
context = await browser.new_context(storage_state=f"state/{account_id}.json")
the rule I follow is to snapshot after every authenticated action that could rotate a cookie, not only at the end of a run. a rotating session cookie means the file you saved at login is stale after the first page load. if the agent crashes and restarts from the login-time file, it presents a cookie the server has already retired.
worked examples
these are constructed from documented limits and simple arithmetic. they are not benchmarks from my fleet, and I have not invented measurements for them.
example 1: a Google API integration in testing status
Google’s OAuth 2.0 documentation lists several conditions under which a refresh token stops working. as of October 2026 these include: the user revoked access, the token went unused for six months, the user changed their password when the token carries Gmail scopes, the account exceeded the limit of 100 refresh tokens per Google account per OAuth client ID (the oldest are silently invalidated), and, for apps whose consent screen is in “Testing” publishing status with an external user type, the refresh token expires after seven days.
the seven day rule is the one that catches operators. you build an agent that reads a mailbox or a spreadsheet, it works for a week, and then every run returns invalid_grant. nothing is wrong with your code. the arithmetic is that a job scheduled weekly, from a project left in Testing, has a token that expires on the same day the job is due to use it. the fix is on the Google Cloud side (publish the app, which for sensitive scopes may involve verification) and not in the agent. what the agent can do is classify invalid_grant as “needs human re-consent”, stop, and raise an alert with the account id, rather than retrying in a loop.
the 100-token limit is the quieter one. if each deploy or each worker runs a fresh consent flow and stores a new refresh token, 100 deploys later your earliest workers lose access without any error on the worker’s side until its next refresh. one consent per account, stored centrally, avoids it.
example 2: a 40-worker fleet sharing one rotating refresh token
take an API that rotates refresh tokens on every use, which is the behaviour RFC 9700 recommends. access tokens last one hour. you run 40 workers against one service account and each one refreshes on its own schedule.
without coordination, worker A refreshes and receives token R2, invalidating R1. worker B, which loaded R1 at startup, refreshes with R1 a few minutes later. the server sees a retired token replayed. under the reuse-detection behaviour the BCP describes, it may revoke the entire token family, including R2. now all 40 workers are logged out at once, and the failure looks like a mass outage rather than a race.
the arithmetic is the argument for a single refresher. with a one hour access token and a five minute skew, you need exactly one refresh per 55 minutes for the whole account, whether you have 4 workers or 400. so make one process own the refresh, write the result to a shared store, and have workers only read access tokens. that is what the store.lock() call in the earlier snippet is standing in for. for this reason I treat the refresh token as a single-writer resource, with the same care as a database row that two writers must never both update. the idempotency write-up covers the same discipline for side effects. a token refresh is a side effect with a very bad retry story.
example 3: a cookie-session portal with idle and absolute limits
consider a customer portal, one that you own or have the account holder’s consent to automate, with a 30 minute idle timeout and a 12 hour absolute timeout. these are numbers I am choosing for illustration, which is typical of what such products configure. the agent runs a 20 hour reconciliation.
the idle timeout is solvable. a lightweight authenticated request every 10 to 15 minutes keeps the session warm, provided that endpoint counts as activity for that server, which you verify rather than assume. the absolute timeout is not solvable by any keepalive. at hour 12 the session ends and the next request lands on the login form. the correct design is to schedule a planned re-login before hour 12 (say at hour 11), at a safe checkpoint between units of work, rather than discovering the boundary mid-form-submission. the run then has two sessions, with the handoff recorded in the job state. if the login needs a one-time code or a human approval, the planned re-login also needs a planned human, so the checkpoint is also where the job pauses and notifies.
edge cases and failure modes
silent logout that returns 200
many sites do not return 401 on expiry. they redirect to a login page that returns 200, or render a logged-out view of the same URL. an agent that only checks status codes sees success. counter: define a positive auth assertion per target, something that must be present only when logged in (an account menu element, a specific JSON field, a username in the DOM), and check it after every navigation that matters, or at least on a fixed cadence. treat absence as “session lost”, not “page changed”. this is the single highest-value check I know of, and it is cheap. pair it with tracing so you can see the exact step where auth vanished, which is the use case behind the tooling in best tools for tracing and debugging AI agents.
stale storage state after rotation
as above, a state file saved at login becomes wrong once the server rotates the cookie. the agent restarts, loads the old file, and gets logged out, sometimes with a security flag on the account for presenting a retired credential. counter: snapshot on a cadence and after authenticated writes, write atomically (temp file then rename) so a crash mid-write does not leave a truncated JSON, and keep the previous snapshot as a fallback. also keep one state file per account and never let two workers write the same one.
identity binding and network changes
some services bind a session to client characteristics. a session created from one network and replayed from another may be challenged or terminated, and a cloned browser profile moved to a different machine can look like theft. I have no documented numbers for how any given site scores this, and it differs per site, so I do not claim any. what I can say is the operational rule: keep a session on the same egress and the same browser profile it was created on. if you move it, expect to re-authenticate. when sessions have to be tied to a stable mobile identity in Singapore, one option is cloudf.one, which rents real Android phones in Singapore on dedicated hardware, each with a persistent Singapore mobile IP, controlled from the browser. it is Singapore-only, so it is only relevant if your target account lives there. for CGNAT-specific behaviour on mobile networks, see carrier-grade NAT explained for AI agents.
attaching to a human’s live session
if the agent attaches to a Chrome instance the user is also using, you inherit their session, their logouts, and their tab activity. the user signing out of the site in another tab kills your agent’s session too. and the reverse happens: your agent’s logout kills theirs. the tradeoffs between that and a dedicated profile are in attaching to an existing Chrome session vs launching a fresh browser. for long-running work I default to a dedicated profile per account, with the user’s consent to the delegated access.
retry storms on expired credentials
a naive retry policy treats a 401 like a transient error and retries with backoff. with 40 workers that is 40 refresh attempts or 40 failed logins in a burst, which can lock the account. counter: classify errors. 401 with a valid refresh path gets one coordinated refresh. invalid_grant, a login page, or a CAPTCHA or verification challenge is terminal for the automated path. the agent stops, saves state, and alerts a human. it does not try to get around the challenge. a verification step is the site telling you a person has to be involved, and the fix is a person.
clock skew and early expiry
access tokens carry expires_in relative to when the server issued them, and your clock may disagree. a skew of a few minutes absorbs it. for cookies with Expires, comparing against a drifting VM clock can make you think a valid cookie is dead or an expired one is live. run NTP, and prefer the server’s Date header when you need to reason about a specific cookie.
what we learned in production
the biggest shift was moving from “log in at the start of the run” to “authentication is a service the run depends on”. a small auth component owns the credentials, the timers, the refresh lock and the logged-in assertion. workers ask it for a bearer token or a ready browser context and never touch the raw refresh token or the state file. once that existed, most expiry incidents turned into a single class of event with a single handler. the lifecycle also gets a status, such as healthy, expiring, needs_human, and the dashboards read that status instead of grepping logs. this lines up with how the phone fleet setups work in running a fleet of phone-use agents, where per-device session state has to be owned by exactly one controller.
the second lesson is to write the unknowns down. for every target I keep a small record: documented timeouts with a source link and the month I checked (“as of October 2026”), observed timeouts with the date observed, whether refresh rotates, and what the logged-in assertion is. vendors change these policies, and the record tells me which numbers are stale. when a number turns out wrong, the incident review updates the record instead of living in someone’s head. none of this is clever. it is bookkeeping, and it has saved me more runs than any prompt change. if you want a deeper index of what I have written on agent operations, the blog index lists it all.
references and further reading
- RFC 6265: HTTP State Management Mechanism, the cookie specification, including
Expires,Max-Ageand session cookie semantics. - MDN: Set-Cookie header, attribute reference and browser lifetime caps.
- RFC 6749: The OAuth 2.0 Authorization Framework, access and refresh tokens and the
invalid_granterror. - RFC 9700: Best Current Practice for OAuth 2.0 Security, refresh token rotation and replay handling.
- Google: Using OAuth 2.0 to access Google APIs, the documented refresh token expiry conditions.
- related on this site: what is Web Bot Auth for AI agents, on identifying your agent to sites that support it.
Written by Xavier Fok
disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-10-19.