TL;DR: Choose opaque session cookies for a first-party web app whose server must control logout and session state. Choose stateless JWT bearer tokens for API clients or services that need to verify claims independently, provided you can accept tokens remaining valid until expiry or add revocation checks.
The Core Difference
Here, JWT bearer means a signed JWT explicitly sent in Authorization: Bearer …, verified without a per-token session lookup. Session cookie means an opaque ID automatically sent by the browser, with identity and session data held on the server. This is distinct from cookie vs session authentication, which compares self-contained tokens inside cookies with server-backed sessions.
JWT is a format; bearer is how possession grants access. A bearer token need not be a JWT, and a JWT can be stored in a cookie. Signed JWT claims are encoded, not concealed: signing does not encrypt them. See RFC 7519 section 3 and RFC 6750.
| Decision | Stateless JWT bearer | Opaque session cookie |
|---|---|---|
| Request credential | Explicit Authorization header | Browser-managed Cookie header |
| Request validation | Signature and claim validation | Session ID lookup plus authorization checks |
| Logout authority | Verifiers need expiry or a revocation signal | Server invalidates the stored session |
| Data carried each time | Encoded header, claims and signature | Session identifier; session data stays server-side |
| Browser storage | Chosen by the application | Cookie jar; HttpOnly can deny script access |
| Rotation | Issue new tokens; handle refresh-token reuse | Replace session ID and invalidate the old one |
Server-backed sessions are an implementation choice, not a requirement of cookies. For a concrete implementation, Django’s session documentation describes server-side storage, flush() for logout and cycle_key() for changing the ID.
Logout Is A Server Decision
Deleting a token from the UI logs out that copy. It does not revoke a copy taken before logout. A verifier checking only signature, expected claims and expiry has no new information about the logout. RFC 7009 section 3 describes the tradeoff: self-contained access tokens need coordination for immediate revocation, or a short lifetime to limit the exposure.
For a session, invalidate the server record and expire the browser cookie. Clearing only the cookie leaves a stolen ID usable. The consequence for a distributed design is that every accepting server must see the invalidation; do not promise immediate logout while a cache still accepts the old record.
Three different changes are often called rotation:
- Session ID rotation: issue a fresh ID on authentication and stop accepting the old ID. MDN recommends regenerating session cookies on authentication to prevent session fixation.
- Refresh token rotation: exchange a refresh token for a new one and invalidate the previous value. Retain their relationship so reuse can trigger revocation. This is defined in RFC 9700 section 4.14.2; it does not itself invalidate existing access JWTs.
- Signing key rotation: change which key signs new JWTs. If the old verification key remains accepted, old tokens remain acceptable subject to their claims. Removing that key affects all tokens signed with it, rather than one login.
Storage Changes The Attack
For a first-party browser app, an illustrative cookie is:
Set-Cookie: __Host-session=opaque-session-id; Path=/; Secure; HttpOnly; SameSite=Lax
Generate an unpredictable ID in the application; the displayed value is a placeholder. __Host- requires Secure, Path=/ and no Domain in supporting browsers. HttpOnly stops scripts reading the cookie but still allows it on script-initiated requests. These rules are documented under MDN Set-Cookie.
Cookie authentication needs CSRF defenses because the browser supplies credentials to eligible requests. An attacker cannot make an ordinary cross-site form attach your application’s bearer header, but injected script running in your origin can act as your application. HttpOnly limits credential theft; it does not stop that script sending authenticated requests. MDN’s XSS description explains that injected code runs with the site’s privileges.
localStorage is persistent script-accessible storage, not a JWT requirement (MDN). Holding a token in memory avoids leaving it in persistent storage, but does not establish an XSS boundary. Putting the JWT in an automatically sent cookie changes its CSRF exposure, even though the token format stays the same.
Do Not Let The Token Choose Its Verifier
The JWT alg header is untrusted input. RFC 8725 sections 2.1 and 3.1 describe two failures: accepting alg: none without signature verification, and treating an RSA public key as an HMAC secret after an attacker changes RS256 to HS256.
Configure an algorithm allowlist in the verifier and bind each key to one algorithm. Reject none for this signed bearer design. Require the expected issuer and audience, and enforce your required expiry policy; a valid signature alone is not authorization. Use separate validation rules for different token purposes. These checks follow RFC 8725 sections 3.8–3.12 and the expiry semantics in RFC 7519.
When Each Fits
First-party web app: start with an opaque session cookie if your admin panel needs per-device logout or current server-held session data. Your application can keep the API token on the backend when calling another service.
API consumed by independent clients: a bearer token gives a CLI or mobile client an explicit credential to attach. Use a JWT if independent claim verification is a requirement; an opaque bearer token remains an option when central revocation matters more.
Service-to-service: audience-bound signed tokens fit services that share an issuer’s verification keys but do not share a session store. Each service must still enforce permissions. Bearer possession is sufficient to use the credential, so protect it in transit with TLS (RFC 6750 section 5).
These are design choices, not claims that one format is faster or always safer.
The Common Mistake: Ignoring Size Per Request
The signed JWT carries its claims and signature on every authenticated request; the opaque cookie carries an ID. Neither has a universal byte size. Measure the actual serialized credential before deciding whether the difference matters, and do not equate header text size with compressed HTTP/2 or HTTP/3 traffic.
For an offline comparison, replace these placeholders with disposable test values. This uses TextEncoder to count UTF-8 bytes of the header text, excluding line endings and transport compression; it does not measure network traffic:
const jwt = 'YOUR_TEST_JWT'
const sessionId = 'YOUR_TEST_SESSION_ID'
const bytes = (text) => new TextEncoder().encode(text).byteLength
console.log({
bearerHeaderBytes: bytes(`Authorization: Bearer ${jwt}`),
sessionHeaderBytes: bytes(`Cookie: __Host-session=${sessionId}`)
})
Check On The Wire
Use placeholders in documentation and a test account when exercising your own endpoints:
curl -i https://api.example.com/me \
-H 'Authorization: Bearer YOUR_TEST_JWT'
curl -i https://app.example.com/me \
-b '__Host-session=YOUR_TEST_SESSION_ID'
In DevTools, inspect Request Headers for Authorization or Cookie, and the login response for Set-Cookie. After logout, repeat a protected request with the old credential in a controlled test. A UI redirect alone does not prove revocation. Do not paste captured credentials into shared logs.
Related
- Authorization for the bearer header.
- Sessions and state for server session storage.
- Cookie vs session authentication for tokens carried inside cookies.
- CORS vs CSRF for browser request defenses.