· 13 min read

Reconsidering JWT and Session Cookies

# Software Engineering
This article was auto-translated from Chinese. Some nuances may be lost in translation.

In this post, I want to talk about my thoughts on JWT and session mechanisms. Before that, you can refer to this article:

What JWT and Session Cookies Are

A session cookie is the most traditional authentication mechanism. The flow is very simple:

  1. The user logs in, and the server generates a random session ID
  2. The session ID and the corresponding user data are stored on the server side (database or cache)
  3. The server returns the session ID to the browser via Set-Cookie
  4. On subsequent requests, the browser automatically includes this cookie
  5. After receiving the request, the server looks up the corresponding user data using the session ID

The session ID itself contains no information; it is merely a key. All data is stored server-side.

In a browser runtime, cookies are automatically attached to every request. In practice, you need to implement CSRF Tokens to prevent attackers from forging requests. If the server only checks whether there is a “logged-in cookie,” it may mistakenly assume the request was made by you. So the server cannot rely on the cookie alone; it must also verify a token that only your site’s pages can obtain and send along.

A CSRF token is a random verification code issued by the server to the frontend, used to prevent other websites from impersonating your logged-in identity and secretly sending requests. Attackers can usually trick your browser into making a request, but they cannot obtain the correct CSRF token, so forged requests are blocked.

The flaw of cookies is that they cannot determine whether a request is legitimate, which is why this must be implemented separately at the application layer.

JWT (JSON Web Token)

JWT takes the exact opposite approach: it encodes user information directly into the token.

A JWT consists of three parts: Header (algorithm information), Payload (user data), and Signature. When the server issues a JWT, it signs the first two parts with a secret key. Later, when a JWT is received, the signature can be verified to confirm the data has not been tampered with, without querying the database.

JWT is not encryption. Anyone can decode it and read the data inside. The signature prevents tampering, not reading. So do not put passwords or sensitive data in a JWT.

The Current Mainstream Approaches

In practice, authentication can generally be divided into three approaches:

Approach 1: JWT + Client Storage

Store the JWT in client-side storage. This can be sessionStorage, localStorage, IndexedDB, or even an in-memory JavaScript variable. Each time an API request is made, JavaScript retrieves the token from storage and puts it into the Authorization header.

// Store token
function setToken(token) {
  sessionStorage.setItem('jwt', token)
}

// Include 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 characteristic of this approach is that authentication is controlled entirely by JavaScript, not by the browser’s cookie mechanism. In 2011, Jesse Hallett made this point in 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 the browser mechanism (cookies) to the application layer (JavaScript) has the benefit that CSRF attacks do not work in this architecture — malicious websites cannot trigger requests with an Authorization header through <form> or <img>.

The trade-off is losing the protection of httpOnly. If the site has an XSS vulnerability, attackers can directly read the token.

Put a short-lived JWT in a httpOnly=false cookie (or sessionStorage) so the frontend can read the token contents to determine UI state; at the same time, put a long-lived refresh token in a httpOnly=true cookie to reduce the risk of leakage.

This is the architecture recommended in Hasura’s JWT Best Practices, combined with a silent refresh mechanism:

// Automatically refresh before the 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, so the browser sends it automatically
  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 tries to strike a balance between security and flexibility: the short-lived JWT limits the impact of leakage, while the httpOnly protection on the refresh token safeguards the long-term credential.

In SPA-centric applications, this architecture has a clear UX advantage. Because the JWT payload includes an expiration time (exp), the frontend can proactively refresh the token before it expires, and the whole process happens in the background with no user awareness. You avoid the situation where “you filled out an entire form, hit submit, and only then discovered the 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 expiration

  if (refreshAt > 0) {
    setTimeout(async () => {
      const newToken = await refreshToken()
      scheduleTokenRefresh(newToken)
    }, refreshAt)
  }
}

Doing this with session cookies requires extra design. Cookie expiration is controlled by the browser, and the frontend cannot directly know how much session time is left. Although you can return the remaining time via a response header or a dedicated API, this is not something session cookies provide out of the box; you have to implement it yourself.

This is the traditional method introduced earlier. The server generates a random session ID and returns it via Set-Cookie; 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, looks up the database once, finds the corresponding user data, and that’s it.

Comparison of the Approaches

JWT + Client StorageJWT + Cookie + Refresh TokenSession Cookie
XSS RiskToken can be read by JSShort-lived JWT can be read; refresh token protected by httpOnlyProtected by httpOnly; inaccessible to JS
CSRF RiskNatively immune (no cookie usage)Requires SameSiteRequires SameSite
RevocationCannot revoke; must wait for expirationRefresh token can be revokedCan be revoked at any time
Server StateStatelessRefresh token needs to be stored in DBSession stored in DB or cache
Cross-domain SupportEasy (Authorization header)Relatively easyDifficult (third-party cookie restrictions)
Implementation ComplexityMediumHigh (silent refresh, token rotation)Low

The Hidden Costs of JWT

Once you choose JWT, there are some costs you have to take into account.

Cannot Be Revoked

Once a JWT is issued, it remains valid until it expires. If you set the JWT lifetime to one day, then even if the user logs out during that day, the token is still valid. Set it to one week, and it becomes even riskier — if the token leaks, attackers get a full week-long window.

Hasura’s recommendation is to keep JWTs valid for only 5–15 minutes and pair them with a refresh token for silent refresh. But as they themselves also admit:

Token deny-listing introduces central state again, and brings us back to what we had before using JWTs at all.

Once you need to revoke tokens (whether through a blacklist or a refresh token), you are back in a stateful world, and in the end you still need somewhere to manage those tokens.

Algorithm Selection

JWT supports multiple signature algorithms. If signing and verification happen on the same server, HMAC (HS256) is enough: fast, short key, and sufficiently secure.

RSA requires longer keys, and the verification computation cost is also higher. Unless you need different services to verify independently (a public/private key separation scenario), HMAC is the more reasonable choice.

Another easily overlooked issue: if your JWT library does not strictly restrict accepted algorithms, an attacker may send a JWT claiming to require “no signature” (alg: none), and the server might accept it. This is not just a theoretical risk; it has actually happened in real vulnerabilities.

Secret Key Rotation

The signing key is a huge single point of failure. If the key leaks, the consequences are: attackers can forge any JWT, for any user, any permission, any expiration time. The server cannot tell, because the signature is perfectly valid.

You need a key rotation mechanism. That means supporting multiple key versions at the same time — new JWTs signed with the new key, old JWTs verified with the old key — and the kid (Key ID) in the JWT header is used to handle this.

These are all things session cookies do not force you to worry about. A session ID is just a random string: no signature, no key, no algorithm to attack.

Refresh Tokens Still End Up Hitting the Database

One of JWT’s biggest selling points is statelessness: the server doesn’t need to store anything; it just verifies the signature.

But as mentioned earlier, JWTs cannot be revoked. The refresh token mechanism exists to solve this contradiction: keep JWTs very short-lived (a few minutes) to reduce the impact of leakage, while issuing a long-lived refresh token so users do not have to log in frequently. When the JWT expires, the client uses the refresh token to get a new JWT, and the user hardly notices.

But the refresh token must exist on the server side (database or cache), because you need the ability to revoke it. When a user logs out, delete the refresh token; when abnormal behavior is detected, invalidate all refresh tokens. All of these operations require database access.

The flow becomes:

  1. Short-lived JWTs (a few minutes) for normal requests — no database lookup
  2. When the JWT expires, use the refresh token to get a new one — database lookup
  3. The refresh token itself must be managed — stored in the database

If the number of concurrent users is within the tens of thousands, the performance difference here is not that obvious compared with simply using session cookies and hitting the database each time. The cost you save by not querying the DB on every request gets eaten up by the extra token management logic.

CSRF and XSS in Modern Architectures

CSRF: Not as Scary as Before

CSRF used to be the biggest pain point of cookie-based authentication. When Jesse Hallett wrote Cookies Are Bad for You in 2011, the SameSite attribute did not yet exist, and CSRF token implementation was easy to get wrong — he described stateful CSRF tokens in Ajax applications as “a constant source of bugs.”

At the time, moving authentication away from cookies and into JavaScript-controlled Authorization headers was indeed a reasonable choice. Even in 2026, I still think JWT is much simpler than session cookies in some scenarios.

In a separated frontend/backend architecture, the CSRF risk is lower than you might think. Most API requests use Content-Type: application/json, which makes them non-simple requests; the browser will send a preflight request first, and malicious cross-site forms cannot trigger this kind of request. Combined with SameSite=Lax, this blocks most CSRF attack vectors. In modern frontend-backend separation architectures, you may not even need to implement a traditional CSRF token mechanism.

XSS: Multi-layer Defense

XSS (Cross-Site Scripting) means an attacker injects malicious JavaScript into your page, and when other users browse that page, the script executes in their browsers. No matter which solution you use, XSS is a risk you must face. The difference is the scope of impact after XSS occurs.

httpOnly cookies can prevent attackers from taking the token outright, but they cannot stop attackers from directly sending requests or injecting scripts using the victim’s browser. One insight I got from Huli is that the role of httpOnly is to limit the blast radius of leakage, not to defend against XSS itself.

The real defense is to prevent XSS from happening in the first place:

  • Modern frontend frameworks (React, Vue, Svelte) escape output by default
  • A strict Content Security Policy (CSP) that restricts script-src origins and forbids inline scripts can further reduce the success rate of XSS attacks

If XSS really does happen, shortening token lifetime can limit the damage. Keep access tokens at 5–15 minutes and refresh tokens at 30–60 minutes. Even if an attacker obtains an access token, the usable window is very short; and if the refresh token is stored in an httpOnly cookie, the attacker cannot get it at all, meaning they cannot regenerate an access token in their own environment.

If you consider httpOnly=false unacceptable, then you should also not accept putting tokens in localStorage, because the XSS risk is exactly the same for both.

When Do You Actually Need JWT?

JWT is truly valuable in these scenarios:

  • Multiple microservices need to verify identity independently: the API Gateway issues JWTs, and downstream services verify them with a public key without each service needing to query the auth database
  • Cross-domain single sign-on (SSO): services on different domains need to share authentication state
  • Large-scale traffic: the number of concurrent users is high enough that session lookups become a performance bottleneck

If services need to work across domains, JWT really is more convenient. Browser policies on third-party cookies are becoming stricter and stricter — Chrome is expected to phase them out gradually, and Safari has long blocked them by default. Under this trend, using cookies to pass identity across domains is becoming increasingly unreliable. JWT, transmitted via the Authorization header, is completely unaffected by these restrictions.

Don’t Build Your Own Member System

No matter whether you choose JWT or session cookies, the implementation details of an authentication system are easy to get wrong: password hashing, token expiration handling, refresh token rotation, multi-device logout, brute force protection. Every part has security risks, and in most cases you do not need to build this from scratch.

There are already mature solutions available:

  • Auth.js (formerly NextAuth.js): supports multiple frameworks, built-in OAuth integration, session management out of the box
  • Supabase Auth: if you already use Supabase, auth is integrated directly, with OAuth and magic link support
  • Clerk, Auth0: fully managed authentication services, suitable for teams that do not want to maintain auth infrastructure themselves

These tools handle the details for you, such as OAuth flow, session management, and token rotation. You can focus on product logic instead.


The core question when choosing an authentication scheme is only one: what do your actual requirements look like? If you have a monolithic architecture, same-domain setup, and a relatively small user base, session cookies are enough. If you need cross-domain support, microservices, or large-scale traffic, then JWT is worth the extra complexity. A simple solution does not mean an unprofessional one; the complexity you choose should match the real-world need.

Related Posts

Explore Other Topics