
Session cookies vs JWTs vs opaque tokens: which auth approach should you use?
Session cookies vs JWTs vs opaque tokens all solve the same problem of identifying a user on subsequent requests after they log in. Most comparisons treat this as a binary choice between session cookies and JWTs, leaving opaque tokens as a footnote. This article covers all three. It explains how each stores identity, how each is validated, what the attack surface looks like for each, and gives you a decision framework for picking the right approach for your architecture.
Quick comparison
The table below maps the key dimensions across all three approaches.
What each approach is
All three mechanisms persist identity after login. The differences are where that identity lives and how it gets verified on each request.
Session cookies
With session-based authentication, the server creates a session record after login and stores it in memory, a database, or Redis. The browser holds only a session ID in an HttpOnly cookie. On each request, the server looks up that session ID in its store to retrieve your identity and permissions. Nothing sensitive travels in the cookie itself. OWASP specifies that "the session ID content (or value) must be meaningless to prevent information disclosure attacks." Use a cryptographically secure pseudorandom number generator (CSPRNG) with a size of at least 128 bits.
The login flow has five steps: (1) you submit credentials, (2) the server verifies them and creates a session with a session ID, (3) that session ID is stored in your browser as a cookie, (4) on each subsequent request the cookie is verified against the server's session store, (5) on logout the session is destroyed on both sides.
The canonical OWASP cookie looks like this:
Set-Cookie: __Host-SessionID=<random-128-bit-value>; Secure; HttpOnly; SameSite=Strict; Path=/
Each attribute does specific work. Secure ensures the cookie is only sent over HTTPS, preventing session ID disclosure through man-in-the-middle attacks. HttpOnly blocks JavaScript from reading the cookie, preventing session ID theft via XSS. SameSite=Strict prevents the browser from sending the cookie on cross-site requests, providing CSRF defense. The __Host- prefix requires the cookie to be set with Secure, must not have a Domain attribute, and must use Path=/. This prevents subdomain forgery and HTTPS downgrade attacks.
JWTs (JSON Web Tokens)
RFC 7519 defines a JWT as "a compact, URL-safe means of representing claims to be transferred between two parties." The token consists of three period-separated segments: header.payload.signature, each base64url-encoded. The payload carries standard registered claims (iss, sub, aud, exp, jti) plus any custom claims your application needs.
A decoded JWT payload looks like this:
{
"iss": "https://auth.example.com",
"sub": "user-123",
"aud": "api.example.com",
"exp": 1719360000,
"jti": "a9b3c2d1-...",
"role": "admin"
}
The payload above is base64url-encoded, not encrypted. Anyone who intercepts this token can read every claim. Never put secrets, PII, or sensitive data in a JWT payload unless you use JWE encryption.
Validation is local and stateless. Any service with the public key can verify the signature through cryptographic computation without a database lookup. This is why JWTs dominate microservices architectures. The server doesn't store the JWT. Authgear describes it this way: "each one contains all the data required for verification."
Opaque tokens
An opaque token is a high-entropy random string with no readable content. RFC 7662 specifies that the contents of tokens are opaque to clients. The client does not need to know anything about the content or structure of the token itself. Nordic APIs describes an opaque token as "a unique, randomized alphanumeric string carrying no readable or actionable information for the client or external observers."
Because the token cannot be parsed locally, every validation requires a network call to the authorization server's /introspect endpoint. RFC 7662 defines this introspection protocol. A resource server sends an HTTP POST with the token:
POST /introspect HTTP/1.1
Host: server.example.com
Accept: application/json
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer 23410913-abewfq.123483
token=2YotnFZFEjr1zCsicMWpAA
The authorization server responds with {"active": true} if the token is valid, or {"active": false} if it has been revoked or has expired. RFC 7662 specifies that for inactive tokens "the authorization server SHOULD NOT include any additional information about an inactive token, including why the token is inactive."
How revocation works
Revocation is the core operational difference between these three approaches. Getting it wrong in production creates real security gaps.
Revoking a session cookie
Delete the server-side session record. The cookie becomes an orphan that references nothing, and account lockout is immediate. As LoginRadius puts it: "You can invalidate sessions instantly. Logout actually means something."
Revoking a JWT
JWTs have no native revocation. OWASP states that "a token only becomes invalid when it expires. The user has no built-in feature to explicitly revoke the validity of a token." There are three workarounds, each with tradeoffs.
Short-lived access tokens. Keep JWTs short-lived (OWASP's code example uses 15 minutes as an illustrative value). This bounds the revocation window, so even if a token is stolen, it becomes useless when it expires. The tradeoff is that users must refresh their token frequently.
Refresh token rotation. Pair a short-lived JWT access token with a long-lived opaque refresh token. Revoking the refresh token stops renewal. When the access token expires, the client uses the refresh token to get a new one. The current access token stays valid until it expires, so a revocation window still exists.
Denylist keyed on (jti, iss). OWASP recommends that on logout, "a dedicated service will add the token's identifying claims (jti and iss) to the denylist resulting in an immediate invalidation of the token for further usage in the application." Critically, the denylist must be keyed on the jti + iss pair, not on raw token bytes or a hash of them. ECDSA signature malleability means a valid signature (r, s) has a second equally valid form (r, (-s) mod n), producing different token bytes that would bypass a hash-based denylist. Non-strict JWT parsing in many libraries introduces similar variants. Combining jti with iss ensures a globally unique, malleability-resistant key. Denylist entries can be purged once the token's exp has passed. Note that a denylist reintroduces a lookup per request, partially giving up the statelessness benefit.
Revoking an opaque token
Delete the backend record. The next introspection call returns {"active": false}, and revocation is immediate with no workarounds needed.
Token storage and the CSRF vs XSS tradeoff
Where you store the token matters as much as which token type you choose.
Storing tokens in HttpOnly cookies
The browser sends the cookie automatically with every request, which creates CSRF risk because a malicious cross-site request will include the cookie. The primary mitigation is SameSite=Strict or SameSite=Lax. JavaScript cannot read an HttpOnly cookie, so XSS cannot exfiltrate the token itself. OWASP notes that "the HttpOnly cookie only protects the confidentiality of the cookie; the attacker cannot use it offline, outside of the context of an XSS attack." XSS can still perform actions as the user, but the credential itself cannot be stolen and used elsewhere. This is OWASP's preferred storage pattern for both session cookies and browser-based JWTs.
Storing tokens in localStorage or sessionStorage
OWASP explicitly warns: "Do not store authentication tokens, session IDs, JWTs, refresh tokens, or any credential in localStorage or sessionStorage. These APIs are accessible to any JavaScript executing in the origin, so a single XSS vulnerability discloses every token." Additionally, localStorage data persists across browsing sessions and the standards do not require it to be encrypted at rest. There is no CSRF risk since tokens in localStorage are not sent automatically, but the XSS exposure is severe.
Sending tokens via Authorization header
This is the typical pattern for API clients and native mobile apps. The Authorization: Bearer <token> header must be read from storage by JavaScript, so it carries the same XSS exposure as localStorage if that's where the token lives. If the token is held in memory only (a runtime JavaScript variable), it is lost on page refresh, which is impractical for browser SPAs but acceptable in native apps. This pattern has no CSRF risk because the browser does not send custom headers automatically on cross-site requests.
The Backend-for-Frontend (BFF) pattern
OWASP recommends the BFF pattern for SPAs, where the browser never touches the token directly. The BFF server holds it and proxies authenticated requests, removing XSS token-theft risk entirely. This is the most secure pattern for browser-based applications.
Security attack surface per token type
Each approach has a characteristic attack to defend against.
Session fixation: the session cookie attack
Session fixation is an attack where an attacker sets a known session ID in the victim's browser before the victim logs in, then uses that same ID after authentication to hijack the session. OWASP's mitigation is mandatory: "the session ID must be renewed or regenerated by the web application after any privilege level change within the associated user session." This covers login, password changes, permission changes, and role escalation. Session hijacking via network interception is mitigated by the Secure attribute, which ensures the cookie is only sent over TLS.
Algorithm confusion: the JWT attack
The none-alg attack occurs when an attacker changes the alg header field to "none" and removes the signature. OWASP describes that "some libraries treated tokens signed with the none algorithm as a valid token with a verified signature." This attack was widespread partly because RFC 7519 required conforming implementations to support both HS256 and "none" as mandatory algorithms. The fix is to always explicitly specify the expected algorithm when calling your JWT verification function and to reject any token that doesn't match, never trusting the alg value from the token itself.
Token sidejacking is a second JWT-specific risk, where an attacker intercepts a valid JWT and uses it from a different machine. OWASP's mitigation is to add a user fingerprint to the payload, consisting of a SHA-256 hash of a random string that is also stored in a hardened HttpOnly cookie. An attacker with the stolen JWT cannot replicate the cookie from the token alone.
JWT information disclosure is the third risk. Since JWT payloads are base64url-encoded but not encrypted, any intercepted token exposes its claims. Never embed secrets, PII, or sensitive system information in a JWT payload unless you use JWE.
Introspection endpoint abuse: the opaque token attack
RFC 7662 specifies that "if left unprotected and un-throttled, the introspection endpoint could present a means for an attacker to poll a series of possible token values, fishing for a valid token." To prevent this, the authorization server must require authentication of any protected resource calling the introspection endpoint. The endpoint must use TLS 1.2. For revoked or invalid tokens, the server must return only {"active": false}, with no additional details about why the token is inactive, to avoid disclosing internal authorization server state.
Scalability and the authorization server dependency
Session stores require coordination at scale
On a single server, sessions are trivial. Add a load balancer and you face an immediate choice: sticky sessions (fragile) or a shared session store (Redis, database). Add regions and replication delays become a factor. LoginRadius describes it directly: "Add a load balancer and you're choosing between sticky sessions or a centralized store. Add another region, and you're dealing with replication delays. Add more services and suddenly everything depends on the health of the session layer." Sessions tend to break operationally, and infrastructure complexity grows with scale.
JWTs scale without coordination
Any server with the public key can validate any JWT without coordination, no shared state, no sticky routing. Authgear describes that "the backend does not need to store the JWT token, and each one contains all the data required for verification." JWT failures at scale are logical rather than operational: long-lived tokens, weak revocation policies, and misconfigured algorithms. The infrastructure scales cleanly, but governance is where the complexity lives.
Opaque tokens require a central authorization server
Every request depends on the authorization server being reachable. Nordic APIs notes that "if that backend infrastructure experiences downtime, the entire ecosystem's capacity to authenticate and process requests is severely compromised." Caching introspection responses reduces latency but introduces a revocation window, where a compromised token may still be accepted until the cache expires. RFC 7662 allows caching but notes that "this creates a window during which a revoked token could be used at the protected resource." Highly sensitive environments should disable caching entirely.
Which approach to use
There is no universally correct answer. The right choice depends on your architectural constraints.
The hybrid pattern used in production
Most mature systems don't pick one approach. Nordic APIs describes the consensus: "the debate between JSON Web Tokens and opaque tokens is a false dichotomy. The consensus among enterprise integration architects is to reject a binary choice, opting instead for a hybrid architecture."
The standard pattern pairs short-lived JWT access tokens for actual API calls with a long-lived opaque refresh token held by the authorization server. JWT access tokens validate locally and stateless, so any service verifies them without calling the authorization server. The opaque refresh token is the revocation lever. Delete the backend record and no new access tokens can be issued. Once the current short-lived JWT expires (OWASP's code examples use 15 minutes as an illustrative window), the attacker cannot renew it.
In practice, after login the system issues a short-lived JWT access token. Every API call includes that token in the Authorization header. When the access token expires, the app silently uses the refresh token to get a new one. The user doesn't re-authenticate. LoginRadius describes this flow: "Identity keeps flowing without server-side memory."
A second pattern worth naming is to use opaque tokens externally (for third-party API consumers) and JWTs internally (for microservice fan-out). Nordic APIs notes that "standard JWTs are simply encoded, any entity intercepting the token can inspect its payload." Opaque tokens prevent that claim leakage to external parties while JWTs preserve stateless validation inside your own infrastructure.
FAQ
Can a JWT be revoked before it expires?
JWTs have no native revocation mechanism. OWASP states that "a token only becomes invalid when it expires" and that users have no built-in way to revoke it. The three workarounds are: (1) keep access JWTs short-lived so the window is small, (2) pair with a revocable opaque refresh token, (3) maintain a denylist keyed on the jti + iss pair. Option 3 reintroduces a database lookup per request.
Is storing a JWT in localStorage safe?
Storing JWTs in localStorage is not safe. OWASP explicitly warns not to store authentication tokens, JWTs, or refresh tokens in localStorage or sessionStorage. Any XSS vulnerability in your application exposes every token stored there. Use an HttpOnly cookie instead, or a Backend-for-Frontend pattern that keeps the token server-side entirely.
When should I use opaque tokens instead of JWTs?
Use opaque tokens when you need immediate, reliable revocation (compliance requirements, healthcare, financial applications), when you don't want claims exposed to third-party API consumers, or when your authorization server and API services are close enough that introspection latency isn't a concern. Nordic APIs identifies opaque tokens as "the first choice for transmitting highly sensitive data that must remain entirely abstracted from the browser or end-user."
What is session fixation and how do I prevent it?
Session fixation is an attack where an attacker sets a known session ID in the victim's browser before they log in, then uses that same ID after authentication to hijack the session. OWASP's required mitigation is to regenerate the session ID immediately after every authentication event, covering login, privilege changes, and password changes.
What is the none-alg attack on JWTs?
An attacker changes the alg header field to "none" and removes the signature. Vulnerable JWT libraries accepted unsigned tokens as valid, trusting the header's claim that the token had already been verified. The fix is to always explicitly specify the expected algorithm when calling your JWT verification function, and to reject any token that doesn't match.