Introduction
Stateless services are services that do not store any client state between requests. Every request contains all information needed to process it, making the system highly scalable and easier to maintain. This architectural pattern has become the foundation of modern web services, enabling horizontal scaling, microservices adoption, and cloud-native applications.
In this comprehensive guide, we’ll explore the principles of stateless architecture, compare stateful and stateless approaches, implement JWT and OAuth authentication, address security considerations, and examine best practices for building robust stateless services in 2026.
Understanding Stateless Architecture
What Makes a Service Stateless
A stateless service doesn’t retain any information about previous requests. Each request is independent and self-contained. The server doesn’t need to know what happened in previous requests because all necessary context is included in the current request itself.
This is in contrast to stateful services, which store client information between requests, typically in server-side sessions. While stateful services were the norm in early web development, the need for massive horizontal scaling has made stateless architecture the preferred choice for modern applications.
Key characteristics of stateless services:
- No session data stored on the server
- All authentication data in the request itself
- Each request is independent and can go to any server
- Easy horizontal scaling without session affinity
- Simplified server logic and maintenance
Stateful vs Stateless
Stateful Architecture
Traditional stateful architecture uses server-side session storage:
# Server stores session data
class SessionStore
@@sessions = {}
def self.create(user_id)
session_id = SecureRandom.uuid
@@sessions[session_id] = { user_id: user_id, created_at: Time.now }
session_id
end
def self.get(session_id)
@@sessions[session_id]
end
def self.destroy(session_id)
@@sessions.delete(session_id)
end
end
# Client sends session ID
class UserController < ApplicationController
def show
session = SessionStore.get(params[:session_id])
@user = User.find(session[:user_id])
end
end
Problems with stateful architecture:
- Scaling challenges: Sessions must be replicated across servers
- Single point of failure: Session store downtime affects all users
- Memory limitations: Session storage grows with users
- Geographic distribution: Difficult to route users to distant servers
- Deployment complexity: Must manage session persistence
Stateless Architecture
In stateless architecture, all necessary information travels with the request:
# No server-side session storage
class UserController < ApplicationController
def show
token = extract_token(request.headers)
payload = JWT.decode(token, SECRET_KEY, true, algorithm: 'HS256')
@user = User.find(payload[:user_id])
end
end
Benefits:
- Horizontal scaling: Any server can handle any request
- No session storage: Reduces infrastructure complexity
- Geographic flexibility: Users can be routed to any server
- Simplified deployment: No session migration needed
- Better fault tolerance: No shared state to fail
JWT Implementation
Understanding JWT Structure
A JWT consists of three parts separated by dots:
xxxxx.yyyyy.zzzzz
Header.Payload.Signature
- Header: Contains the algorithm and token type
- Payload: Contains the claims (data)
- Signature: Verifies the token’s authenticity
Creating JWT Tokens
require 'jwt'
class AuthService
SECRET_KEY = ENV['JWT_SECRET']
EXPIRATION = 24.hours
def self.encode(user_id, additional_claims = {})
payload = {
user_id: user_id,
exp: EXPIRATION.from_now.to_i,
iat: Time.now.to_i,
jti: SecureRandom.uuid # Unique token ID for revocation
}.merge(additional_claims)
JWT.encode(payload, SECRET_KEY, 'HS256')
end
def self.decode(token)
begin
payload = JWT.decode(token, SECRET_KEY, true, algorithm: 'HS256')
payload[0]
rescue JWT::DecodeError => e
Rails.logger.warn "JWT decode error: #{e.message}"
nil
end
end
def self.expired?(token)
payload = decode(token)
return true unless payload
payload['exp'] < Time.now.to_i
end
end
Using JWT in Rails
class ApplicationController
attr_reader :current_user
before_action :authenticate_request
private
def authenticate_request
token = extract_token_from_header
if token
payload = AuthService.decode(token)
if payload
@current_user = User.find(payload[:user_id])
else
render json: { error: 'Invalid token' }, status: :unauthorized
end
else
render json: { error: 'Missing token' }, status: :unauthorized
end
end
def extract_token_from_header
header = request.headers['Authorization']
header&.split(' ')&.last
end
end
Client-Side Token Storage
// Store token in localStorage (common but less secure)
function login(email, password) {
fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
.then(res => res.json())
.then(data => {
localStorage.setItem('authToken', data.token);
});
}
// Better: Use httpOnly cookie
async function loginSecure(email, password) {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Send cookies
body: JSON.stringify({ email, password })
});
return response.json();
}
// Include token in requests
function fetchUserData() {
const token = localStorage.getItem('authToken');
fetch('/api/user', {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => res.json())
.then(data => console.log(data));
}
OAuth 2.0 and OpenID Connect
OAuth 2.0 Flow
OAuth 2.0 provides authorization framework for token-based authentication:
// Authorization Code Flow
const authUrl = new URL('https://authorization-server.com/oauth/authorize');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'read:profile read:email');
authUrl.searchParams.set('state', generateRandomState());
// Redirect user to authUrl
window.location.href = authUrl.toString();
// After authorization, handle callback
async function handleCallback(code) {
const response = await fetch('/api/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID
})
});
const { access_token, refresh_token } = await response.json();
// Store tokens securely
}
OpenID Connect
OpenID Connect adds an identity layer on top of OAuth 2.0:
// OIDC Discovery
const wellKnownUrl = 'https://auth.example.com/.well-known/openid-configuration';
async function getOIDCConfig() {
const response = await fetch(wellKnownUrl);
return response.json();
}
// ID Token contains user identity
async function verifyIdToken(idToken, nonce) {
const config = await getOIDCConfig();
// Get JWKS from provider
const jwksResponse = await fetch(config.jwks_uri);
const jwks = jwksResponse.json();
// Verify signature and claims
const payload = await jwtVerify(idToken, jwks, {
issuer: config.issuer,
audience: CLIENT_ID,
nonce
});
return payload;
}
Benefits of Stateless Services
| Benefit | Description |
|---|---|
| Scalability | Any server can handle any request |
| Simplicity | No session storage to manage |
| Performance | No session lookup overhead |
| Reliability | No single point of failure |
| Testing | Easier to test in isolation |
| Cloud Native | Perfect for container orchestration |
| CDN Friendly | Can be cached at edge |
Scaling Comparison
# Stateful service scaling
# Requires sticky sessions or session replication
services:
api:
replicas: 3
# All three need access to shared session store
# Adding replicas increases complexity
# Stateless service scaling
# Simple horizontal scaling
services:
api:
replicas: 10
# Any instance can handle any request
# Just add more containers
Challenges and Solutions
Token Revocation
One challenge with stateless services is that tokens cannot be invalidated before expiration. Solutions:
# Solution 1: Token Blacklist (Redis)
class TokenBlacklist
REDIS_KEY = 'blacklisted_tokens'
def self.blacklist(token)
payload = JWT.decode(token, nil, false)[0]
exp = payload['exp']
jti = payload['jti']
# Store with expiration matching token expiry
Redis.current.zadd(REDIS_KEY, exp, jti)
end
def self.blacklisted?(token)
payload = JWT.decode(token, nil, false)[0]
jti = payload['jti']
# Check if JTI exists in blacklist
Redis.current.zscore(REDIS_KEY, jti)
end
end
# Solution 2: Short-lived tokens + refresh tokens
class RefreshToken
ACCESS_TOKEN_EXPIRY = 15.minutes
REFRESH_TOKEN_EXPIRY = 30.days
def self.create(user_id)
refresh_token = SecureRandom.uuid
Redis.current.setex(
"refresh:#{refresh_token}",
REFRESH_TOKEN_EXPIRY,
{ user_id: user_id }.to_json
)
refresh_token
end
def self.refresh(refresh_token)
key = "refresh:#{refresh_token}"
stored = Redis.current.get(key)
return nil unless stored
user_id = JSON.parse(stored)['user_id']
Redis.current.del(key) # Rotate refresh token
# Issue new access and refresh tokens
{
access_token: AuthService.encode(user_id),
refresh_token: create(user_id)
}
end
end
Token Refresh
class TokenController < ApplicationController
def refresh
refresh_token = params[:refresh_token]
tokens = RefreshToken.refresh(refresh_token)
if tokens
render json: tokens
else
render json: { error: 'Invalid refresh token' }, status: :unauthorized
end
end
end
Security Considerations
# Token security best practices
class SecureAuthService
# 1. Use strong secrets
SECRET_KEY = ENV.fetch('JWT_SECRET') {
raise 'JWT_SECRET must be set'
}
# 2. Use appropriate algorithms
ALGORITHM = 'HS256' # Or 'RS256' for asymmetric
# 3. Include necessary claims
def self.encode(user_id)
payload = {
sub: user_id.to_s, # Subject (user ID)
iat: Time.now.to_i, # Issued at
exp: 15.minutes.from_now.to_i, # Expiration
jti: SecureRandom.uuid, # Unique identifier
iss: 'your-app.com', # Issuer
aud: 'your-api.com' # Audience
}
JWT.encode(payload, SECRET_KEY, ALGORITHM)
end
# 4. Verify all claims
def self.decode(token)
begin
payload = JWT.decode(
token,
SECRET_KEY,
true,
algorithm: ALGORITHM,
iss: 'your-app.com',
aud: 'your-api.com',
verify_iss: true,
verify_aud: true
).first
# 5. Check blacklist if using short tokens
return nil if TokenBlacklist.blacklisted?(token)
payload
rescue JWT::DecodeError
nil
end
end
end
Best Practices for 2026
1. Use Short-Lived Access Tokens
# Access tokens: 5-15 minutes
ACCESS_TOKEN_EXPIRY = 15.minutes
# Refresh tokens: Days to weeks
REFRESH_TOKEN_EXPIRY = 7.days
2. Implement Refresh Tokens
- Use cryptographically secure random tokens
- Store in secure, httpOnly cookies
- Implement token rotation (issue new refresh token on use)
- Set appropriate expiration
3. Store Tokens Securely
// Prefer httpOnly cookies over localStorage
// This prevents XSS attacks from stealing tokens
// Set cookie with secure flags
document.cookie = `token=${accessToken};
HttpOnly;
Secure;
SameSite=Strict;
Path=/`;
// For refresh tokens, always use httpOnly cookies
// Access tokens can use memory for sensitive apps
4. Validate All Token Claims
Always verify:
- Expiration (
exp) - Not-before (
nbf) - Issued-at (
iat) - Issuer (
iss) - Audience (
aud) - JWT ID (
jti)
5. Implement Token Blacklisting
For force logout and suspicious activity:
class TokenBlacklistService
def self.blacklist(token, reason = nil)
payload = JWT.decode(token, nil, false)[0]
jti = payload['jti']
exp = payload['exp']
Redis.current.setex(
"blacklist:#{jti}",
exp - Time.now.to_i,
{ reason: reason, blacklisted_at: Time.now.to_i }.to_json
)
end
def self.blacklisted?(jti)
Redis.current.exists("blacklist:#{jti}")
end
end
6. Use Rate Limiting
class RateLimitMiddleware
def initialize(app)
@app = app
end
def call(env)
token = extract_token(env['HTTP_AUTHORIZATION'])
if token
key = "rate:#{extract_jti(token)}"
count = Redis.current.incr(key)
if count == 1
Redis.current.expire(key, 60) # Reset every minute
end
if count > 100 # 100 requests per minute
return [429, { 'Content-Type' => 'application/json' },
[{ error: 'Rate limit exceeded' }.to_json]]
end
end
@app.call(env)
end
end
7. Implement Device Fingerprinting
// Combine token with device fingerprint
function getDeviceFingerprint() {
const fp = {
userAgent: navigator.userAgent,
language: navigator.language,
platform: navigator.platform,
screenWidth: screen.width,
screenHeight: screen.height,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
};
// Hash the fingerprint
return sha256(JSON.stringify(fp));
}
// Include in token
async function authenticatedRequest(url, options = {}) {
const token = localStorage.getItem('accessToken');
const fingerprint = await getDeviceFingerprint();
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
'X-Device-Fingerprint': fingerprint
}
});
}
Conclusion
Stateless services using JWT provide an excellent foundation for building scalable, maintainable APIs. By eliminating server-side session storage, you gain simplicity and horizontal scalability at the cost of additional complexity in token management.
Key takeaways:
- Use short-lived access tokens (5-15 minutes) with refresh tokens
- Implement proper token security (https, httpOnly cookies, secure storage)
- Include all relevant claims (iss, aud, exp, jti)
- Implement token blacklisting for force logout
- Use rate limiting to prevent abuse
- Consider device fingerprinting for additional security
- Monitor for anomalous token usage patterns
Comments