Stateful vs Stateless Architecture: JWT, Sessions & Cookies Explained
Understand the difference between stateful and stateless systems. Learn how Cookies, Sessions, and JWT work with practical code examples and diagrams.
What is State in Programming?
In programming, state refers to the condition of a system at a particular point in time. It is the data your application remembers.
Think about having a conversation with someone. If you ask "What was the answer?", they need to remember the previous question to give you a meaningful response. That memory is state.
State represents the data that is stored and used to keep track of the current status of the application.
Stateful Applications
A stateful application retains state or context about its interactions with users, systems, or components. The server keeps track of the state of each session or interaction and maintains that information based on the user's past requests.
The key characteristic of stateful applications is that the state is persisted on a durable storage solution, so the application survives a restart. Stateful applications save both past and present information.
How Stateful Architecture Works
In a stateful architecture, the server remembers the client's data. All future requests must be routed to the same server using a load balancer with sticky sessions enabled. This ensures the server is always aware of the client.
What are Sticky Sessions?
Sticky sessions is a load balancer configuration that routes a user's requests consistently to the same backend server for the duration of their session. This is different from traditional load balancing where requests can go to any available server in a round-robin pattern.
Because the session data lives on a specific server, the load balancer must track which user belongs to which server and route accordingly.
Problems with Stateful Architecture
Stateful architecture has two significant drawbacks:
- Not fault tolerant - If a server crashes, the user's session data is lost because that data only existed on that particular server.
- Difficult to scale - Adding or removing servers is complex because sessions are tied to specific servers. You cannot simply add more servers to handle increased load without migrating or replicating session data.
Use Cases for Stateful Applications
Stateful applications work well in several scenarios:
- User-centric applications - Social media apps and e-commerce sites keep track of a logged-in user's session, including their preferences or shopping cart items.
- IoT systems - Continuously send, receive, and analyze data in a feedback loop. Your home thermostat adjusts based on past temperature readings and your preferences.
- AI and ML model training - Models learn from data and remember that data. During the training phase, the model is constantly changing and learning as parameters are being adjusted. However, once training is complete, the state of the model freezes and the model becomes stateless for inference.
Stateless Applications
A stateless application or process does not retain information about the user's previous interactions. There is no stored knowledge of or reference to past transactions. Each transaction is made as if from scratch for the first time.
An Important Clarification
The term "stateless" can be confusing because it seems to imply that no state exists at all. That is not always the case.
There are actually two forms of stateless architecture:
Form 1: Stateless servers with shared external storage
- Individual servers do not hold any local state
- They are interchangeable and any server can handle any request
- The application still maintains state in a shared external store like Redis that all servers can access
- The servers are stateless, but the system as a whole has state stored somewhere
Form 2: Truly stateless with JWT
- No state is stored anywhere on the server side
- The client carries all the necessary information in the JWT token itself
- When the server receives a request, it verifies the token signature and reads the user data directly from the token payload
- Nothing is looked up from any database or cache
- The server has no memory of any user - it just validates the token and extracts the information it needs
So when we talk about stateless architecture, it can mean either:
- Servers are stateless but state exists in shared storage
- Everything is stateless and state is carried by the client
How Stateless Architecture Works
In a stateless architecture, HTTP requests from a client can be sent to any of the servers. The servers do not store session data locally.
State is stored in a separate database or cache like Redis that is accessible by all the servers. This creates a fault-tolerant and scalable architecture because web servers can be added or removed as needed without impacting state data.
HTTP is Stateless, TCP is Stateful
Looking at network protocols helps clarify this concept:
- HTTP - A stateless protocol where each request is independent and carries no knowledge of previous requests
- TCP - A connection-oriented and stateful protocol that maintains connection state throughout the communication
Benefits of Stateless Architecture
Stateless architecture offers two major advantages:
- Fault tolerant - If a server dies, another one takes over seamlessly. No session data is lost because the data lives in the shared cache.
- Easy to scale - You can simply add more servers behind the load balancer without worrying about session affinity.
Use Cases for Stateless Applications
- REST APIs - Transfer a representation of the state of a resource to the requester. Each API call is independent and contains all the information needed to process the request.
- Microservices - Allow each core function within an application to exist independently. Each service handles its own domain and communicates with others through well-defined interfaces.
- Serverless architectures - Designed to respond to events in isolation. Functions spin up to handle a request and shut down afterward. They do not retain context from previous actions.
- LLM inference - Large language models operate in a stateless manner during inference. The model weights are fixed and do not change between requests.
Stateful vs Stateless Comparison
| Aspect | Stateful | Stateless |
|---|---|---|
| State Retention | Stores information about the interaction in database or distributed memory | Does not store information on server, transaction starts fresh |
| Session Dependence | Each request depends on data from previous interactions | Each request is treated as new and independent |
| Storage Dependence | Requires persistent storage on each server | State stored externally in shared cache or database |
| Scalability | Difficult due to sticky sessions and server affinity | Easy because any server can handle any request |
| Fault Tolerance | Low, server crash means session data loss | High, server crash does not affect user sessions |
How Cookies Work
A cookie is a small piece of data that the server sends to your browser. Your browser stores it and sends it back with every future request to that server.
The Cookie Mechanism
The process works like this:
- When you first visit a website, the server includes a
Set-Cookieheader in its response - This header contains the cookie data
- Your browser stores this cookie locally
- For all subsequent requests to the same website, your browser automatically includes the cookie in a
Cookieheader - The server can now recognize these requests as coming from the same browser
Code Example: Setting and Reading Cookies
const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cookieParser());
app.use(express.json());
app.post('/login', (req, res) => {
const { username, password } = req.body;
// In production, validate against database
if (username === 'john' && password === 'secret') {
res.cookie('sessionId', 'abc123xyz', {
httpOnly: true, // Prevents JavaScript access for XSS protection
secure: true, // Only sent over HTTPS
sameSite: 'strict', // CSRF protection
maxAge: 24 * 60 * 60 * 1000, // 1 day in milliseconds
});
res.json({ message: 'Logged in successfully' });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
app.get('/profile', (req, res) => {
const sessionId = req.cookies.sessionId;
if (sessionId === 'abc123xyz') {
res.json({ user: 'John Doe', email: 'john@example.com' });
} else {
res.status(401).json({ error: 'Not authenticated' });
}
});
app.post('/logout', (req, res) => {
res.clearCookie('sessionId');
res.json({ message: 'Logged out successfully' });
});
app.listen(3000, () => console.log('Server running on port 3000'));
How Sessions Work
A session represents a more robust way to maintain state between requests. While cookies are stored entirely on the client side, sessions use a hybrid approach.
The Session Mechanism
- The server generates a unique session ID when a user logs in
- This session ID is sent to the browser as a cookie
- The server stores the actual session data associated with this ID on the server side (memory, database, or Redis)
- When the browser makes subsequent requests including the session ID cookie, the server looks up the associated session data
What is Stored on the Server?
When a user logs in, the server:
- Creates a session ID (a unique random identifier)
- Stores session-related data server-side:
- User ID
- Authentication status
- Roles and permissions
- Preferences
- Other metadata like IP address or login time
- Sends only the session ID back to the client
This makes sessions:
- Dynamic - The server can update or revoke session data instantly
- Flexible - Permissions can be modified without issuing a new session
Code Example: Session-Based Authentication with Redis
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
// Initialize Redis client
const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect().catch(console.error);
// Configure session middleware with Redis store
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000,
},
}),
);
// Simulated user database
const users = [
{
id: 1,
email: 'john@example.com',
passwordHash: '$2b$10$hashedpasswordhere',
role: 'admin',
},
];
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find((u) => u.email === email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Store user data in session
req.session.userId = user.id;
req.session.email = user.email;
req.session.role = user.role;
req.session.loginTime = new Date().toISOString();
res.json({ message: 'Logged in successfully' });
});
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
app.get('/profile', requireAuth, (req, res) => {
res.json({
userId: req.session.userId,
email: req.session.email,
role: req.session.role,
});
});
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('connect.sid');
res.json({ message: 'Logged out successfully' });
});
});
app.listen(3000, () => console.log('Server running on port 3000'));
Sessions are stored in Redis, so even if the server restarts, users remain logged in. The server can instantly revoke a session by deleting it from Redis.
How JWT Works
With JWT, everything needed for authentication is stored inside the token itself. The server does not store any session-related data. It only holds the secret key to verify tokens.
JWT is Stateless
When a user logs in, the server generates a JWT containing:
- User ID
- Roles or permissions
- Expiration timestamp
- Any other metadata
This data is encoded and signed using a cryptographic algorithm like HMAC with SHA-256.
JWT Structure
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9.signature
It has three parts separated by dots:
- Header - Contains metadata about the token including the algorithm (
alg) and type (typ) - Payload - Contains the user data (also called claims). There are three types:
- Registered claims - Predefined claims like
iss(issuer),exp(expiration),sub(subject),aud(audience) - Public claims - Custom claims defined in the IANA JSON Web Token Registry
- Private claims - Custom claims agreed upon between parties
- Registered claims - Predefined claims like
- Signature - A cryptographic hash used for verification
Security Warning: The payload is only Base64Url encoded, not encrypted. Anyone can decode and read it. Do not put sensitive information (passwords, secrets) in the JWT payload unless the token is encrypted.
How the JWT Signature is Created
The server creates the signature by:
- Combining the Base64Url encoded header and payload
- Applying the HMAC-SHA256 algorithm using the secret key to create the signature
- Joining the header, payload, and signature with dots to form the final token
How JWT Verification Works
When the client sends back the JWT with each request, the server validates it by:
- Splitting the token into its three parts
- Recomputing the signature from the header and payload using the same secret key
- Comparing it with the signature in the token
- If they match, the token is valid. If they do not match, the request is rejected.
Code Example: JWT Authentication from Scratch
const express = require('express');
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const SECRET_KEY = 'your-256-bit-secret-key-here';
const users = [
{
id: 1,
email: 'john@example.com',
passwordHash: '$2b$10$hashedpasswordhere',
role: 'admin',
},
];
// Base64Url encode
function base64UrlEncode(data) {
return Buffer.from(data)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// Base64Url decode
function base64UrlDecode(str) {
let padded = str.replace(/-/g, '+').replace(/_/g, '/');
while (padded.length % 4) {
padded += '=';
}
return Buffer.from(padded, 'base64').toString();
}
// Create signature
function createSignature(data, secret) {
return crypto.createHmac('sha256', secret).update(data).digest('base64url');
}
// Create JWT token
function createToken(payload, expiresInSeconds) {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payloadWithExp = {
...payload,
iat: now,
exp: now + expiresInSeconds,
};
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payloadWithExp));
const dataToSign = encodedHeader + '.' + encodedPayload;
const signature = createSignature(dataToSign, SECRET_KEY);
return dataToSign + '.' + signature;
}
// Verify JWT token
function verifyToken(token) {
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid token format');
}
const encodedHeader = parts[0];
const encodedPayload = parts[1];
const receivedSignature = parts[2];
const dataToSign = encodedHeader + '.' + encodedPayload;
const computedSignature = createSignature(dataToSign, SECRET_KEY);
const signaturesMatch = crypto.timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(computedSignature),
);
if (!signaturesMatch) {
throw new Error('Invalid signature');
}
const payload = JSON.parse(base64UrlDecode(encodedPayload));
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
throw new Error('Token expired');
}
return payload;
}
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find((u) => u.email === email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = createToken(
{ userId: user.id, email: user.email, role: user.role },
3600, // 1 hour
);
res.json({ token: token, expiresIn: 3600 });
});
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authorization header required' });
}
const token = authHeader.split(' ')[1];
try {
const payload = verifyToken(token);
req.user = payload;
next();
} catch (err) {
return res.status(403).json({ error: err.message });
}
}
app.get('/profile', requireAuth, (req, res) => {
res.json({
userId: req.user.userId,
email: req.user.email,
role: req.user.role,
});
});
app.listen(3000, () => console.log('Server running on port 3000'));
The JWT Logout Problem
You cannot instantly invalidate a JWT. If a user logs out, their token remains valid until it expires. The client should delete the token from storage, but there is no way to force invalidation on the server side.
Workarounds include:
- Using short token expiration times (15 minutes)
- Maintaining a token blacklist (adds state and somewhat defeats the purpose)
- Implementing refresh token rotation
Sessions vs JWT: Choosing the Right Approach
| Scenario | Sessions | JWT |
|---|---|---|
| Instant logout needed | Works well, destroy session immediately | Token remains valid until expiry |
| Multiple servers | Needs shared session store like Redis | Stateless, any server can verify |
| Mobile apps and SPAs | Cookie handling can be complex | Token stored in app memory works well |
| Admin or banking portals | Good for tight control and audit trails | Cannot revoke access instantly |
| Microservices and APIs | Harder to pass cookies across domains | Bearer token in header works well |
| Dynamic permissions | Update session data anytime | Must issue new token |
Use JWT for scalability and APIs where statelessness is beneficial, such as mobile apps and microservices. Use sessions when you need tight control, dynamic permissions, or instant logout, such as banking or admin portals.
Summary
| Concept | Description | Data Location | Best Use Case |
|---|---|---|---|
| Stateful | Server remembers user state | Server memory or database | Traditional web apps |
| Stateless | Server does not store user state | External cache or client | APIs and microservices |
| Cookie | Small data stored in browser | Browser storage | Session IDs and preferences |
| Session | Server-side data linked to cookie | Server, database, or Redis | Apps needing instant logout |
| JWT | Self-contained signed token | Client holds the token | Scalable APIs and mobile apps |
References
- Stateful vs Stateless Applications - Red Hat
- HTTP Cookies - MDN Web Docs
- JWT Introduction - JWT.io
- JSON Web Token RFC 7519 - IETF
- Express Session Middleware - Express.js
- Understanding Sessions - Auth0
- Session Management Cheat Sheet - OWASP
- JWT Best Practices - Auth0