Sessions, Tokens, and Cookies: Which One Is Actually Holding Your Login Together

A support ticket, verbatim, from a team I worked with: "User logs out of the admin app, gets redirected to the login page, and is immediately logged back in without entering anything. Only happens on admin.example.com, not app.example.com. Only in Chrome."

A support ticket, verbatim, from a team I worked with: "User logs out of the admin app, gets redirected to the login page, and is immediately logged back in without entering anything. Only happens on admin.example.com, not app.example.com. Only in Chrome."

Three engineers spent two days on it. The bug was that a cookie had been set with Domain=.example.com by one service and Domain=admin.example.com by another, both named session, and the logout handler cleared exactly one of them. The browser was sending both, in an order the spec doesn't guarantee, and the server was reading the first one it found.

Nothing about that bug is exotic. It is a direct consequence of three different objects — a cookie, a session, and a token — being referred to interchangeably in conversation, in code, and in variable names, right up until the moment their differences produce behaviour nobody predicted.

They are not the same thing, they are not even the same kind of thing, and the distinction is not pedantry. One is a transport mechanism, one is server-side state, one is a self-contained claim. Getting this straight is the difference between reasoning about your login system and guessing at it.

The one-paragraph version

A cookie is a transport. It is a string the browser stores and re-sends on requests matching a set of rules, and it has no opinion about what's inside it.

A session is server-side state. It is a record — in Redis, in Postgres, in memory — that says "this identifier corresponds to this authenticated user, established at this time, with these properties." Its defining feature is that the server can change or delete it at will.

A token is a bearer credential: a string that grants access to whoever holds it, whose validity is established by the token itself (a signature) or by asking the issuer (introspection). Its defining feature is that it is presented, not looked up by the presenter.

Those are three orthogonal choices, and the confusion comes from the fact that the most common real-world setup collapses them: a session identifier, transported in a cookie, functioning as a bearer credential. One object playing all three roles is why people think they're one concept.

Where each one lives, and what can kill it

This table is the thing worth internalizing. Almost every login bug I have debugged is a mismatch in one of these rows.

Cookie Session Token
Lives where Browser cookie jar, keyed by domain + path Server-side store Wherever the client put it — memory, localStorage, a cookie, an Authorization header
Set by Set-Cookie response header (or JS) Your application, on successful auth The authorization server, at the token endpoint
Scoped by Domain + path + SameSite + Secure Whatever your code says aud, scope, and expiry
Killed by Expiry, browser clearing, an overwriting Set-Cookie A DELETE on the store — immediate, unilateral Expiry. Only expiry, unless you built revocation
Server can revoke instantly It can ask nicely Yes No, by design
Survives server restart Yes Only if the store is durable Yes
Readable by JS Only if not HttpOnly Never Usually yes, which is the problem

Read the "killed by" row twice. It contains the single most consequential asymmetry in web authentication: a session dies when you say so; a token dies when it decides to. Everything about revocation latency, logout correctness, and incident response follows from that one difference.

The four architectures you can actually build

Once you separate the roles, the design space is small and enumerable. Most teams pick one by accident.

flowchart TD
    A["What does the client present?"] --> B["An opaque reference"]
    A --> C["A self-contained assertion"]
    B --> D["Session cookie<br/><i>server looks it up</i>"]
    B --> E["Opaque token in header<br/><i>server introspects it</i>"]
    C --> F["JWT in a cookie<br/><i>server verifies signature</i>"]
    C --> G["JWT in a header<br/><i>server verifies signature</i>"]

Session cookie. The default for server-rendered applications. Revocation is a delete. Requires a lookup on every request, which is why the identity-platform-as-caching-problem framing exists. Bound to a browser and a domain, which is a limitation for APIs and a security feature for everything else.

Opaque token in a header. Common for first-party mobile and machine clients. Same revocation properties as a session — it's a reference the issuer resolves — but no cookie semantics, so no CSRF exposure and no domain scoping. Costs you an introspection call per request unless you cache, and caching reintroduces the revocation delay you chose this to avoid.

JWT in a cookie. The one that surprises people, because "JWT" and "cookie" get treated as opposing choices. They're not: the JWT is the content, the cookie is the transport. This is a perfectly good design for a browser SPA — you get HttpOnly (so XSS can't read the credential) plus stateless verification. You also get CSRF back, because cookies are sent automatically, so you need SameSite=Lax at minimum and ideally a double-submit or origin check.

JWT in a header. The default for public APIs and cross-origin SPAs. No CSRF surface, no cookie scoping headaches, works across domains. But the token has to live somewhere JavaScript can reach, which means XSS is credential theft rather than session riding. This is a real and frequently understated downgrade: with an HttpOnly cookie, an XSS lets the attacker act as the user while the page is open; with a token in localStorage, they exfiltrate a credential that keeps working from their machine after the user closes the tab.

There is no universally right answer here. There is a wrong way to choose, which is to pick based on which word appeared in the tutorial you read.

Cookies predate the same-origin policy and their scoping rules reflect that. They are subtly different from every other web security boundary you know, and the differences are exactly where the bugs live.

Cookies are not origin-scoped. They are domain-scoped. The same-origin policy considers scheme, host, and port. Cookies consider only the host, and loosely at that. Consequences: http://example.com and https://example.com share a cookie jar (which is what the Secure flag exists to partially fix), and example.com:8080 and example.com:3000 share one entirely. There is no port isolation for cookies. Two applications on different ports of localhost are one cookie namespace, which is why your local dev environment produces authentication behaviour your staging environment doesn't.

A subdomain can write a cookie your parent domain will read. If Domain=.example.com is set by blog.example.com, then app.example.com receives it. If your session cookie is host-only but some service sets a domain-wide cookie with the same name, the browser sends both, and RFC 6265 does not define which comes first — it recommends ordering by path length, and ties are unspecified. Most server frameworks parse the header and take the first match. This is "cookie tossing," and it is a session-fixation primitive: control any subdomain — an abandoned marketing site, a customer-controlled CNAME, an S3 bucket on a wildcard — and you can inject a session cookie that a sibling application will honour.

The fix has existed since 2016 and is barely used: the __Host- prefix. A cookie named __Host-session is only accepted by the browser if it is Secure, has Path=/, and has no Domain attribute — making it strictly host-only, unwritable by subdomains, and unambiguous. If you have one takeaway from this section, it's this one. Rename your session cookie to __Host-session and an entire class of bugs becomes structurally impossible.

Deleting a cookie requires matching the attributes you set it with. To clear a cookie you send a Set-Cookie with an expiry in the past — and the browser only treats it as the same cookie if the name, domain, and path match. Get the domain wrong and you have created a second expired cookie while the original sits there working fine. This is the bug from the opening paragraph, and it is why logout handlers that "work in testing" fail in production where an extra domain is in play.

SameSite changed the meaning of a redirect. With SameSite=Lax (now the default in Chrome when unspecified), cookies are sent on top-level navigations but not on subresource requests or cross-site POSTs. This is why SAML HTTP-POST binding broke for a lot of people: the IdP POSTs the assertion to your ACS endpoint, that's a cross-site POST, and your Lax session cookie — holding the relay state — isn't sent. It's also why front-channel OIDC logout via hidden iframes stopped working: those are cross-site subresource requests, and no cookie means the RP never sees the logout.

The 4KB limit is a real constraint, not a theoretical one. Per-cookie size limits are around 4096 bytes, and browsers cap total cookies per domain. A JWT with a handful of group claims clears 2KB without trying; add group membership from a large enterprise directory and you are splitting the token across cookies or getting silently truncated — and "silently" is accurate, because a browser that refuses an oversized Set-Cookie does not tell you. If your token holds group memberships, you have a scaling limit that will announce itself when your largest customer onboards.

Third-party cookie partitioning (CHIPS) changes cross-site embedding. A cookie set in a third-party context is now increasingly keyed by the embedding site as well. If your identity system is embedded in a customer's page as an iframe, the session it sees is per-embedder. Design for it deliberately or discover it as an intermittent bug.

The bugs that come specifically from conflation

Each of these is a case where code assumed two of the three objects were the same object.

"We revoked the token but the user is still in." Assumed a token is a session. The token was a signed JWT with 60 minutes left; nothing you did to your database changed what that signature says. Every JWT deployment has a revocation window equal to its access token lifetime, and the only ways out are shorter lifetimes, an introspection or denylist check on sensitive paths, or accepting the window explicitly. Pick one on purpose.

"We cleared the session but the cookie kept working." Assumed a cookie is a session — usually because the session store was flushed while the cookie contained a signed, self-contained payload. Cookies don't reference server state unless you designed them to.

"Logout on one app didn't log out the others." Assumed a session is singular. With SSO, there is an IdP session and one session per relying party, and they have independent lifetimes. Killing the IdP session prevents new logins; it does nothing to the RP session cookies already sitting in the browser. This one has its own article's worth of protocol detail, but the root cause is a counting error: you thought there was one session and there were seven.

"The idle timeout doesn't work in the SPA." Assumed a token refresh is user activity. A background poller silently refreshing every 10 minutes will keep a session alive for a fortnight on a laptop nobody has touched. Idle timeout has to be driven by a signal that means a human did something, and a refresh request from your own code is not that signal.

"MFA is bypassed after the first login." Assumed the session records how it was established. If the session is a boolean authenticated: true, you have discarded the fact that this user got in with a password from a new device and no second factor. Authentication method, factor set, and authentication time belong in the session record, because every step-up decision you make later needs them and no other component knows.

"It works on my machine." Assumed cookies are port-scoped. See above. They aren't.

Choosing, deliberately

The order of questions that actually determines the answer:

How fast must revocation be? If the answer is "immediately, provably, for a regulator" — you need a session or an opaque token with introspection. No amount of clever JWT design gets you unilateral instant revocation, because the whole point of a signed token is that it's verifiable without asking you. If the answer is "within a few minutes is fine," JWTs are on the table.

Is the client a browser you control the domain for? If yes, use an HttpOnly, Secure, __Host-prefixed cookie and stop thinking about it. The browser gives you free storage the attacker's JavaScript cannot read; declining that in favour of localStorage because you're "building an API" is trading a real security property for an architectural aesthetic.

How many services validate the credential, and can they all reach your identity service? This is the case where JWTs earn their keep: dozens of services, some in other regions, some run by other teams, all needing to validate on the hot path. Verification against a cached JWKS is what makes that work. With three services in one cluster, an introspection call to a local service is 2ms and simpler.

What has to be true after your identity service goes down? Sessions and introspection fail closed; signed tokens keep validating. Whether that's a feature or a terrifying property depends on what you're protecting, and it is worth writing down which one you chose.

The reframe

Stop asking "sessions or tokens?" It's a malformed question, like asking "variables or files?"

Ask three questions instead, in this order: What is presented? (a reference, or an assertion) How is it carried? (cookie, header, or both) How does it die? (deletion, expiry, or a revocation list you have to build)

Answer those independently and the architecture writes itself, along with the honest account of what your system does when someone's laptop is stolen at 3pm on a Friday. Answer them by conflation and you get the ticket from the top of this article — three engineers, two days, and a cookie whose Domain attribute nobody had ever looked at.