Revisiting JWT vs. Session Cookies
In this post, I want to share my thoughts on JWT and session-based authentication mechanisms. Before diving in, you might also want to check out this post:
What Are Session Cookies and JWTs?
Session Cookies
Session cookies represent the most traditional authentication mechanism. The flow is very straightforward:
- A user logs in, and the server generates a random session ID.
- The session ID and its associated user data are stored on the server (in a database or cache).
- The server sends the session ID back to the browser via the
Set-Cookieheader. - On every subsequent request, the browser automatically includes this cookie.
- Upon receiving a request, the server uses the session ID to look up the corresponding user data.
A session ID itself contains no information; it is merely a key. All data lives on the server.
In a browser environment, cookies are automatically sent with every request. In practice, you need to implement CSRF tokens to prevent attackers from forging requests. If a server merely checks “is there a login cookie?”, it might mistakenly assume the request was initiated intentionally by the user. Therefore, the server cannot rely on cookies alone—it must also verify a token that can only be obtained and submitted by pages from its own site.
A CSRF token is a random verification code issued by the server to the frontend, designed to prevent other websites from secretly sending requests using your authenticated identity. An attacker can typically trick your browser into sending a request, but they cannot retrieve the valid CSRF token, causing the forged request to be blocked.
The fundamental flaw of cookies is that they “cannot determine whether a request is legitimate,” which is why additional safeguards must be implemented at the application layer.
JWT (JSON Web Token)
JWT takes the complete opposite approach: encoding user information directly into the token itself.
A JWT consists of three parts: Header (algorithm information), Payload (user data), and Signature. When issuing a JWT, the server signs the first two parts using a secret key. When receiving a JWT later, verifying the signature is sufficient to confirm that the data hasn’t been tampered with, eliminating the need for database lookups.
JWTs are not encrypted. Anyone can decode and read the data inside them. The purpose of the signature is to prevent tampering, not to prevent reading. Therefore, never put passwords or sensitive data inside a JWT.
Current Mainstream Approaches
In practice, authentication approaches can roughly be divided into three categories:
Approach 1: JWT + Client Storage
Store the JWT in client-side storage. This could be sessionStorage, localStorage, IndexedDB, or even kept in-memory within a JavaScript variable. Whenever an API request is made, JavaScript retrieves the token from storage and places it into the Authorization header.
// Store token
function setToken(token) {
sessionStorage.setItem('jwt', token)
}
// Attach token when making requests
async function fetchWithAuth(url, options = {}) {
const token = sessionStorage.getItem('jwt')
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
},
})
}
The defining characteristic of this approach is that authentication is fully controlled by JavaScript and does not rely on the browser’s cookie mechanism. Back in 2011, Jesse Hallett argued in his article Cookies Are Bad for You:
The key is to choose a mechanism that is controlled by the web application, not the browser.
Moving authentication from browser mechanisms (cookies) to the application layer (JavaScript) means CSRF attacks are fundamentally neutralized under this architecture—malicious websites cannot trigger requests carrying an Authorization header via <form> or <img> tags.
The tradeoff is losing the protection of httpOnly. If the site has an XSS vulnerability, an attacker can directly read the token.
Approach 2: JWT in Cookie + Refresh Token
Place a short-lived JWT in an httpOnly=false cookie (or sessionStorage), allowing the frontend to read the token’s payload to determine UI states; simultaneously, place a long-lived refresh token in an httpOnly=true cookie to reduce the risk of token leakage.
Hasura’s JWT Best Practices recommends this exact architecture, paired with a silent refresh mechanism:
// Automatically renew before JWT expires
function isTokenExpired(token) {
const claims = JSON.parse(atob(token.split('.')[1]))
return claims.exp * 1000 < Date.now()
}
async function refreshToken() {
// The refresh token is in an httpOnly cookie and sent automatically by the browser
const response = await fetch('/auth/refresh', {
method: 'POST',
})
const { jwt } = await response.json()
sessionStorage.setItem('jwt', jwt)
return jwt
}
async function fetchWithAuth(url, options = {}) {
let token = sessionStorage.getItem('jwt')
if (!token || isTokenExpired(token)) {
token = await refreshToken()
}
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
},
})
}
This approach attempts to strike a balance between security and flexibility: short-lived JWTs limit the blast radius of a leak, while the httpOnly flag on the refresh token protects the long-term credential.
In SPA-heavy applications, this architecture offers noticeable UX advantages. Because the JWT payload contains an expiration timestamp (exp), the frontend can proactively invoke the refresh token endpoint right before expiration. The entire process happens in the background, completely imperceptible to the user. You won’t run into situations where “you fill out an entire form, hit submit, only to find out your session expired and you have to log in again.”
// Set a timer to automatically refresh 1 minute before the JWT expires
function scheduleTokenRefresh(token) {
const claims = JSON.parse(atob(token.split('.')[1]))
const expiresIn = claims.exp * 1000 - Date.now()
const refreshAt = expiresIn - 60 * 1000 // 1 minute before expiry
if (refreshAt > 0) {
setTimeout(async () => {
const newToken = await refreshToken()
scheduleTokenRefresh(newToken)
}, refreshAt)
}
}
Achieving this with session cookies requires additional design. Cookie expiration is managed by the browser, so the frontend cannot natively know how much session time remains. While you could return the remaining lifetime via response headers or a dedicated API, it is not a built-in feature of session cookies and must be implemented manually.
Approach 3: Session Cookie
This is the traditional approach introduced earlier. The server generates a random session ID, returns it via Set-Cookie, and the browser handles the rest automatically.
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
No token management, no silent refresh, no signature verification. The server receives the request, queries the database once, locates the corresponding user data, and you’re done.
Comparison of Approaches
| JWT + Client Storage | JWT + Cookie + Refresh Token | Session Cookie | |
|---|---|---|---|
| XSS Risk | Token readable by JS | Short-lived JWT readable; refresh token protected by httpOnly | Protected by httpOnly; unreachable by JS |
| CSRF Risk | Naturally immune (doesn’t use cookies) | Requires SameSite configuration | Requires SameSite configuration |
| Revocation Capability | Cannot be revoked; must wait for expiration | Can revoke refresh tokens | Revocable at any time |
| Server State | Stateless | Refresh tokens require DB storage | Sessions stored in DB or cache |
| Cross-Origin Support | Easy (Authorization header) | Partially easy | Difficult (third-party cookie restrictions) |
| Implementation Complexity | Medium | High (silent refresh, token rotation) | Low |
The Hidden Costs of JWT
When choosing JWT, several costs must be taken into account.
Inability to Revoke
Once a JWT is issued, it remains valid until it expires. If you set the JWT lifetime to one day, the token stays valid for that entire day even if the user logs out. Setting it to a week is even more dangerous—if the token leaks, the attacker has a full one-week window of opportunity.
Hasura’s recommendation on this is to keep JWT lifetimes down to 5–15 minutes, paired with a refresh token for silent refresh. However, as they acknowledge themselves:
Token deny-listing introduces central state again, and brings us back to what we had before using JWTs at all.
As soon as you need token revocation (whether via blocklists or refresh tokens), you return to a stateful world and ultimately still require a centralized store to manage tokens.
Algorithm Selection
JWT supports multiple signature algorithms. If issuance and verification both happen on the same server, HMAC (HS256) is sufficient: it is fast, uses short keys, and provides adequate security.
RSA requires longer keys and incurs higher computational verification costs. Unless you need different services to verify tokens independently (public/private key separation scenarios), HMAC is usually the more sensible choice.
Another easily overlooked issue: if your JWT library does not strictly enforce the accepted algorithms, an attacker could submit a JWT claiming “no signature required” (alg: none), and the server might simply accept it. This is not just a theoretical risk; it is a vulnerability that actually happened.
Secret Key Rotation
The signing secret is a massive single point of failure. If the secret leaks, an attacker can forge any JWT—for any user, with any permissions, and any expiration date. The server cannot tell the difference because the signature is completely valid.
You need a key rotation mechanism. This means supporting multiple key versions concurrently—new tokens are signed with the new key, while older JWTs are verified with the old key—which is precisely what the kid (Key ID) field in the JWT header is designed to handle.
None of this is a concern with session cookies. A session ID is merely a random string: no signatures, no secret keys, and no cryptographic algorithms to exploit.
Refresh Tokens Go Full Circle: Back to the Database
A major selling point of JWT is that it is stateless: the server doesn’t need to store anything, just verify signatures.
Yet, as mentioned earlier, JWTs cannot be revoked. The refresh token mechanism was created precisely to resolve this contradiction: by keeping the JWT lifetime extremely short (a few minutes), the blast radius of a leak is minimized; meanwhile, a long-lived refresh token is issued so users don’t have to repeatedly log in. When a JWT expires, the client exchanges the refresh token for a new one with virtually zero disruption to the user.
However, refresh tokens must be stored on the server (in a database or cache) because you need the ability to revoke them. You delete the refresh token when a user logs out; you invalidate all refresh tokens when anomalous activity is detected. All of these operations require database lookups.
The workflow becomes:
- Short-lived JWTs (a few minutes) handle general requests—no DB lookup.
- Once the JWT expires, exchange the refresh token for a new one—requires a DB lookup.
- The refresh token itself needs management—stored in a DB.
If your concurrent user base is within tens of thousands, the difference between this and simply querying the database on every request with session cookies is negligible. The performance you saved by skipping DB lookups is swallowed by the extra token management overhead.
CSRF and XSS in Modern Architectures
CSRF: No Longer as Terrifying as It Once Was
CSRF used to be the biggest pain point of cookie-based authentication. Back when Jesse Hallett wrote Cookies Are Bad for You in 2011, the SameSite attribute didn’t exist, and CSRF token implementations were notorious for introducing bugs (he noted in the post that stateful CSRF tokens in Ajax apps were “a constant source of bugs”).
At that time, moving authentication from cookies to a JavaScript-controlled Authorization header was indeed a rational decision. Even looking from 2026, under certain premises, I still think using JWTs can be far simpler than session cookies.
In decoupled frontend-backend architectures, the risk of CSRF is lower than many assume. Most API requests specify Content-Type: application/json, making them non-simple requests. The browser sends a preflight request first, which cross-site malicious forms cannot trigger. Combined with SameSite=Lax, the vast majority of CSRF attack vectors are mitigated. In modern decoupled architectures, you might not even need to implement traditional CSRF token mechanisms.
XSS: Defense in Depth
XSS (Cross-Site Scripting) occurs when an attacker injects malicious JavaScript into your web pages, which then executes in the browsers of other users visiting the page. Regardless of the authentication scheme you pick, XSS is a threat you must reckon with. The difference lies in the blast radius after an XSS attack occurs.
An httpOnly cookie prevents attackers from stealing the token outright, but it cannot stop an attacker from making requests directly from the victim’s browser or injecting further scripts. An insightful point Huli once shared with me: the purpose of httpOnly is to contain the blast radius, not to defend against XSS itself.
The true line of defense is preventing XSS from happening in the first place:
- Modern frontend frameworks (React, Vue, Svelte) escape output by default.
- A strict CSP (Content Security Policy) that restricts
script-srcorigins and bans inline scripts further narrows down the attack surface for successful XSS execution.
If XSS does occur, shortening token lifetimes helps limit the damage. Keep access tokens to 5–15 minutes and refresh tokens to 30–60 minutes. Even if an attacker steals an access token, their window of opportunity is narrow; and if the refresh token is stored in an httpOnly cookie, the attacker cannot access it at all, preventing them from generating new access tokens in their own environment.
If you consider httpOnly=false unacceptable, then you shouldn’t accept storing tokens in localStorage either, as both carry the exact same risk against XSS.
When Do You Actually Need JWT?
JWT truly delivers value in the following scenarios:
- Multiple microservices requiring independent authentication: The API Gateway issues a JWT, and downstream services verify it independently using a public key, eliminating the need for every service to query the central auth database.
- Cross-domain Single Sign-On (SSO): Services hosted on different domains need to share authentication state.
- Massive scale: Concurrent users are so numerous that session lookups become a performance bottleneck.
If your services span across different domains, JWT is indeed much more convenient. Browser policies around third-party cookies are growing increasingly strict—Chrome plans to gradually phase them out, and Safari blocks them by default. Under this trend, relying on cookies to transmit identity across domains is becoming increasingly unreliable. Transmitting JWTs via the Authorization header remains completely unaffected by these restrictions.
Don’t Build Your Own Auth System
Whether you choose JWT or session cookies, authentication implementation details are notorious minefields: password hashing, token expiration handling, refresh token rotation, multi-device logout, and brute-force protection. Every single link in the chain introduces security risks, and in most cases, you shouldn’t build it from scratch.
Mature off-the-shelf solutions already exist:
- Auth.js (formerly NextAuth.js): Supports multiple frameworks, built-in OAuth integrations, and out-of-the-box session management.
- Supabase Auth: Directly integrated if you’re already using Supabase, supporting OAuth and magic links.
- Clerk, Auth0: Fully managed identity services, ideal for teams that prefer not to maintain auth infrastructure themselves.
These tools handle OAuth flows, session management, token rotation, and other nuances for you, freeing you up to focus on core product logic.
The core question when choosing an authentication scheme boils down to this: what are your actual requirements? For a monolithic architecture on a single domain with modest user traffic, session cookies are more than enough. Only when you face cross-domain setups, microservices, or massive traffic scale does JWT justify the extra complexity it introduces. Choosing a simpler solution isn’t unprofessional—the complexity of your choice should simply match your actual needs.
Related Posts
- Dancing with AI From ChatGPT 3.5 to Claude Code, software development underwent a radical transformation in less than three years. Here are one software engineer's observations, reflections, and perplexities in the midst of this revolution.
- In 2026, You Might Not Need AWS Before choosing a cloud platform, calculate the true cost your team pays for AWS.
- Why You Should Deploy Services with ECS When running containerized services on AWS, why ECS is a more pragmatic choice than EC2 and EKS—and how deployment complexity eats away at your budget.
- Revisiting the Disillusionment with Software Engineering With massive advancements in LLMs completely transforming the landscape of software development, building an application now comes with virtually zero barrier to entry.