All three prove, on every request, that the call comes from a user who already logged in, and all three are reasonable choices. Where they differ in practice is revocation: what happens when you need to cut off access before the token would expire on its own. Someone reports a compromised account, an employee leaves, a refresh token has to die. That one question separates the three approaches.
Session cookies: the server holds the state
A session cookie carries very little, usually just a random session ID. The session data (user ID, roles, whatever the app needs) lives on the server, in memory, Redis, or a database, keyed by that ID. The cookie is a lookup key, not a credential in its own right.
That makes revocation easy. Delete the session row and the next request with that cookie finds nothing and gets rejected, right away. The cost is a round trip to the session store on every request, and if you run more than one server instance they all have to share that store (or you use sticky sessions, which have their own problems). That's why session cookies were the default for so long in apps running on one server or in one datacenter, and why they get awkward once the system spreads across regions or independently scaled services.
JWTs: the token holds the state
A JWT is self-contained. The claims (user ID, roles, expiry) are encoded in the token and signed, not looked up. A server can check the signature and trust the claims without touching a database or session store. Any service with the verification key can validate the token on its own, which is what you want when auth has to work across many services or regions without a shared session store.
The downside mirrors the session cookie's strength. Once a JWT is issued, there's nothing on the server to delete. It stays valid until it expires unless you build something extra, usually a blocklist of revoked token IDs that every verifying service checks alongside the signature. That blocklist is shared server-side state, the thing you adopted JWTs to avoid.
Teams that choose JWTs only for the "no shared session store" benefit, and don't plan for revocation, often find the gap the first time they need to force a user out. The token they issued an hour ago is still valid for another six hours.
None of this makes JWTs insecure. A correctly signed and verified JWT is as trustworthy as its signature (see how decoding differs from verifying). The issue is how fast you can revoke one, not whether someone can forge one.
Ways to handle JWT revocation, and what each costs
- Short expiry plus refresh tokens. Issue a JWT that lasts minutes, paired with a longer-lived refresh token that can be revoked on the server because it's checked against a store on every refresh. The damage window shrinks to the access token's lifetime. You pay with a refresh round trip and two token lifecycles to manage.
- A revocation blocklist. Every verifying service checks the token's ID against a shared "revoked" set (often in Redis) as well as the signature. Revocation takes effect immediately, but you're back to a shared lookup. It's a session store again, a smaller one that only tracks revocations.
- Accept the window. For low-risk apps, a short expiry with no revocation machinery is a reasonable trade, as long as someone decided it on purpose and it isn't a gap nobody noticed until an incident.
Refresh-token rotation
Most production systems end up with short expiry plus refresh, so here are the details. The access token, the JWT sent with every API request, expires in minutes, sometimes as few as 5 to 15. A separate refresh token, opaque or not, is issued with it. It lives much longer (days or weeks) and is stored on the server in a table you can revoke from, like a session. The client uses the access token for normal requests. When it expires, the client quietly trades the refresh token for a new access token at a dedicated endpoint.
Rotation adds one rule: each time a refresh token is used, the server invalidates it and issues a new refresh token along with the new access token. Refresh tokens become single-use. If an attacker steals one and uses it, and the real client later presents the same token, the server sees a used token being replayed. That strongly suggests theft, so the server revokes the whole refresh chain and forces a new login. Without rotation, a stolen refresh token stays valid for its full lifetime and nothing tells you it was taken.
The JWT still can't be revoked before its short expiry. But in practice the revocation window shrinks to that access-token lifetime, because the server-side state and checks sit at the refresh step. Regular requests keep most of the JWT's stateless validation, and you get most of a session's revocability. The price is building the refresh and rotation logic correctly, which is fiddly. Races between concurrent refresh requests are the most common bug.
CSRF depends on where the token travels
This is separate from revocation, and it depends on transport, not on whether the token is a JWT, a session ID, or an opaque string. A browser attaches cookies to every request for the cookie's domain, including requests set off by some other site the user has open, not only requests your frontend makes. That's CSRF (cross-site request forgery). A hostile page submits a form or a fetch to your API, the browser attaches the session cookie because it can't tell the request didn't come from your site, and the server sees what looks like a normal authenticated request.
It doesn't matter whether the cookie holds a session ID or a JWT. Putting a JWT in a cookie doesn't protect you from CSRF, because the attack relies on the browser attaching the cookie, not on what's inside it.
A bearer token in an Authorization header behaves differently. Browsers don't add arbitrary headers to cross-origin requests the way they add cookies. A malicious page can still fire a request at your API, but it can't get the browser to attach your access token, because its JavaScript can't read a token your frontend keeps in memory or in localStorage for another origin. That's why bearer-token APIs are usually described as CSRF-resistant by default. The protection comes from the header transport, not from anything specific to JWTs.
You give something up for it. A token kept in localStorage for header-based auth is exposed to XSS instead: any script injected into your page can read localStorage, while an HttpOnly cookie is invisible to JavaScript. So you're choosing between an HttpOnly cookie (open to CSRF, resistant to XSS) and a bearer token in browser storage (resistant to CSRF, more exposed to XSS). The mitigations differ too: CSRF tokens for the cookie case, a strict CSP and careful handling for the storage case. Pick based on which risk your application faces more, not on the token format.
Opaque tokens: session-style lookup, any transport
An opaque token is a random string that means nothing on its own. It's the same idea as a session ID, without being tied to cookies. Most OAuth2 access tokens look like this. The token is an identifier the authorization server can look up, and the client holding it (a mobile app, a server-to-server integration, a browser SPA) sends it however suits the context, often in an Authorization: Bearer header rather than a cookie.
Opaque tokens share the session cookie's main property: the issuer can revoke one immediately by deleting its record, since validating the token always means asking the issuer. They fit best when revocation matters more than skipping a lookup. That's why OAuth2 access tokens are often opaque even in systems that use JWTs elsewhere, for example a JWT as the ID token and an opaque string as the access token that gates API calls.
Picking one
| Session cookie | JWT | Opaque token | |
|---|---|---|---|
| Instantly revocable | Yes | No, without extra infrastructure | Yes |
| Validates without a server round-trip | No | Yes | No |
| Works well across many independent services | Needs a shared session store | Yes, any service with the key can verify | Needs a shared lookup, same as sessions |
| Typical fit | Single app, one datacenter, classic web auth | Stateless auth across many services, short-lived access | OAuth2 access tokens, anywhere revocation matters most |
None of the three is more secure across the board. They take the same constraint, server-side state versus self-contained validation, and resolve it in different directions. If you're debugging a JWT from someone else's system, or checking which claims your own tokens carry, the JWT Decoder below shows the header and payload without a key. Decoding still isn't verifying.