Security
JWTs vs Sessions
One of the fundamental decisions in building authentication is choosing between session-based and token-based approaches. Both work, but they have different trade-offs. Understanding the differences helps you pick the right tool for your application architecture.
What is a Session?
A session is server-side state. When a user logs in, the server creates a session object, typically storing it in memory or a database. This object contains user ID, login time, expiry time, and any other user-specific data. The server generates a unique session ID and sends it to the client in an HTTP cookie.
On subsequent requests, the browser automatically includes the cookie. The server looks up the session ID in its store, validates it hasn't expired, and processes the request with the user's identity. Sessions are simple and stateful—the server is the source of truth about who is logged in.
What is a JWT?
A JWT (JSON Web Token) is a signed token that contains claims (statements about the user). The token is self-contained—all necessary information (user ID, permissions, expiry) is encoded in the token itself. The server signs the token cryptographically, and anyone with the public key can verify the signature without contacting the server.
The token has three parts separated by dots: header.payload.signature. The header specifies the algorithm used for signing. The payload contains the claims (user data). The signature ensures the token hasn't been modified. If someone tampers with the payload, the signature no longer matches, and the token is rejected.
JWT Structure Example
A decoded JWT payload might look like: {"user_id": 123, "email": "user@example.com", "exp": 1700000000}. The exp claim is the expiry timestamp. The server knows this token is valid for a specific time period and doesn't need to query a database—it just validates the signature and checks the expiry.
Stateless Advantage: Horizontal Scaling
JWTs are stateless. The server doesn't store anything—it just verifies the signature. This is a huge advantage for distributed systems. If you have 100 servers, any of them can independently verify a JWT without coordinating with a central session store.
Sessions, by contrast, are stateful. If you have multiple servers, you need a shared session store (Redis, database) that all servers query. This adds operational complexity and becomes a bottleneck—all authentication depends on that single session store.
The Revocation Problem
JWTs have a critical limitation: you can't revoke them before expiry. Once issued, a JWT is valid until its expiry time. If an attacker steals a JWT valid for 1 hour, they have access for up to 1 hour, and you can't revoke it.
Sessions don't have this problem. Delete the session from your database, and the user is logged out immediately. This makes sessions critical for security-sensitive operations like password changes or suspicious activity detection.
The practical solution is to combine them: use short-lived JWTs (5-15 minutes) for API access and refresh tokens for obtaining new access tokens. If you detect suspicious activity, invalidate the refresh token, forcing the user to re-authenticate.
Token Size Trade-offs
JWTs are larger than session IDs. A typical session ID is 32 bytes. A JWT with multiple claims might be 500 bytes. This matters less for cookies but becomes significant if you're sending the JWT in HTTP headers for many API calls.
Additionally, every claim you add to a JWT increases its size. If you encode user roles, permissions, and other data in the token, it grows. Sessions avoid this—the session ID is tiny, and the server stores detailed user data.
Signature Algorithms: RS256 vs HS256
When signing a JWT, you choose an algorithm. The two most common are HS256 (HMAC SHA-256) and RS256 (RSA SHA-256). This choice has security implications.
HS256 uses a single secret key shared between the signer and verifier. The server signs the JWT with the secret, and only servers with that secret can verify it. This is simple but doesn't work well if many independent services need to verify the token—they all need the secret, increasing the risk of exposure.
RS256 uses an asymmetric key pair: a private key for signing and a public key for verification. The server signs with the private key and publishes the public key. Any service with the public key can verify tokens without needing the secret. This is ideal for microservices where many services need to verify tokens issued by a central auth server.
Where to Store JWTs
JWTs can be stored in two places on the client: localStorage or an httpOnly cookie. Both have trade-offs.
localStorage: Accessible via JavaScript, so you can manually include the JWT in request headers. However, if your site has an XSS vulnerability, JavaScript running on your page can steal the token and send it to an attacker.
httpOnly cookie: Cannot be accessed by JavaScript, protecting against XSS. The browser automatically includes it in requests. However, if your site has a CSRF vulnerability, an attacker can make requests on behalf of the user (though CSRF protection headers mitigate this).
The consensus today: store the access token in memory (disappears on page reload), use an httpOnly cookie for the refresh token (persistent but can't be stolen via XSS), and use CSRF protection headers. This gives you resilience against both XSS and CSRF.
Access Tokens and Refresh Tokens
The hybrid pattern is to use two tokens: an access token and a refresh token.
Access token: Short-lived (5-15 minutes), included with every API request. If compromised, damage is limited to that short window. Can be stored in memory (lost on page reload) to protect against long-term XSS theft.
Refresh token: Long-lived (days or weeks), stored in an httpOnly cookie, used only to obtain new access tokens. If the access token expires, the client uses the refresh token to get a new one without re-authenticating. If you detect suspicious activity, revoke the refresh token, and the user is logged out.
This pattern balances security (XSS damage is limited, revocation is possible) with user experience (seamless re-authentication without showing login forms).
Token Expiry Strategies
How long should tokens be valid? This is a security vs. usability trade-off.
Short expiry (5 minutes): If stolen, the window of misuse is tiny. But users experience token expiry frequently, requiring refresh token exchanges. This is good for high-security applications like banking.
Long expiry (1 hour): Balances security and usability for most web applications. An attacker has at most 1 hour to use a stolen token. Users rarely hit token expiry in normal usage.
Sessions vs JWTs: A Direct Comparison
| Aspect | Sessions | JWTs |
|---|---|---|
| Server Storage | Required | Not needed |
| Revocation | Immediate | Only at expiry |
| Horizontal Scaling | Requires session store | No coordination |
| Token Size | Small (32 bytes) | Large (300-800 bytes) |
| Lookup Latency | Database query | Signature verification |
| Cross-origin | Difficult (CSRF) | Works with headers |
| Microservices | Complex | Natural fit |
| Mobile Apps | Difficult | Natural fit |
| Immediate Logout | Yes | No (until expiry) |
| Stateless Scaling | No | Yes |
When to Use Sessions
Use sessions for traditional server-rendered web applications (Next.js pages, Rails, Django). They integrate naturally with server-side rendering and form submissions. Use sessions when you need immediate revocation—if you detect fraudulent activity, you can log the user out instantly. Use sessions for banking and high-security applications where revocation matters more than scalability.
When to Use JWTs
Use JWTs for Single Page Applications (React, Vue, Angular) where you need to send tokens in HTTP headers. Use JWTs for mobile apps—they work naturally without cookies. Use JWTs for microservices and distributed systems where you want stateless authentication. Use JWTs for APIs consumed by external clients—you can issue tokens without managing server-side state.
In practice, most modern applications use both: sessions for traditional flows and JWTs (with refresh tokens) for API endpoints. This gives you the strengths of each approach where they matter most. Pick the pattern that fits your architecture, implement it correctly, and revisit as your system evolves.