Skip to main content
โšก Calmops

Stateless Services Architecture: Complete Guide for 2026

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