How to Store User Sessions in Redis
Web applications need to remember who you are between requests. After you log in, the server creates a session containing your user ID, permissions, and other state. Each subsequent request includes a session ID that the server uses to look up this data. The challenge is where to store sessions when you have multiple application servers behind a load balancer.
Storing sessions in memory on each app server requires sticky sessions, where the load balancer routes each user to the same server. This complicates scaling and failover. Storing sessions in a relational database works but adds latency to every request. Redis solves both problems: it is fast enough for per-request lookups, shared across all app servers, and supports automatic expiration for session cleanup. This guide covers three approaches: simple serialized sessions with strings, field-based sessions with hashes, and keeping sessions alive with sliding expiration.
Which Redis data types we will use
String is used in two ways in this implementation:
- As a serialized session store where all session data (usually JSON) is stored as a single string value. The key is the session ID, which should be cryptographically random. One
GETretrieves the session, oneSETwith expiration creates or updates it. - With TTL for automatic expiration. When creating a session, set an expiration time with
SET key value EX seconds. Redis automatically deletes expired sessions. The TTL can be fixed (session dies after 30 minutes) or sliding (refreshed on each request).
Hash stores session data as individual fields instead of serializing everything into one string. This lets different parts of your application update different fields without reading and rewriting the entire session. The shopping cart module can increment cart_items while the auth module updates last_activity, and you can inspect session contents directly in Redis.
The choice between string and hash depends on how you access session data. If you always read/write the entire session at once, strings are simpler. If you update individual fields frequently, hashes are more efficient.
Storing sessions as serialized strings
The simplest session store serializes all session data into a single string. The key is the session ID, and the value is JSON or another serialization format containing the session data. Set a TTL when creating the session, and Redis automatically deletes it when it expires.
This approach works well when session data is small and you always read or write the entire session at once. Most web frameworks treat sessions this way. The session ID is typically a cryptographically random string stored in a cookie. On each request, the server fetches the session by ID, deserializes it, and makes it available to the application.
The downside is that you cannot update individual fields without reading and rewriting the entire session. For sessions with many fields or frequent partial updates, this adds overhead. You also lose visibility into session contents from Redis tools since the data is opaque serialized bytes.
- Redis
- Python
- TypeScript
- Go
# Create a session with 30 minute expiration
# Session ID should be cryptographically random
SET session:abc123def456 '{"user_id":"user789","role":"admin","cart_items":3}' EX 1800
> OK
# Retrieve the session on subsequent requests
GET session:abc123def456
> '{"user_id":"user789","role":"admin","cart_items":3}'
# Check if a session exists without fetching data
EXISTS session:abc123def456
> 1
# Delete session on logout
DEL session:abc123def456
> 1
# Check remaining TTL for debugging
TTL session:abc123def456
> 1423 (seconds remaining)
import json
import secrets
SESSION_TTL = 1800 # 30 minutes
def create_session(client, user_id: str, data: dict) -> str:
"""Create a new session and return the session ID."""
session_id = secrets.token_urlsafe(32)
session_data = {'user_id': user_id, **data}
client.set(f'session:{session_id}', json.dumps(session_data), ex=SESSION_TTL)
return session_id
def get_session(client, session_id: str) -> dict | None:
"""Retrieve session data by ID."""
data = client.get(f'session:{session_id}')
if data is None:
return None
return json.loads(data)
def delete_session(client, session_id: str):
"""Delete a session (logout)."""
client.delete(f'session:{session_id}')
def update_session(client, session_id: str, data: dict):
"""Update session data, preserving TTL."""
ttl = client.ttl(f'session:{session_id}')
if ttl > 0:
client.set(f'session:{session_id}', json.dumps(data), ex=ttl)
import { randomBytes } from 'crypto';
const SESSION_TTL = 1800; // 30 minutes
async function createSession(client: Redis, userId: string, data: object): Promise<string> {
const sessionId = randomBytes(32).toString('base64url');
const sessionData = { userId, ...data };
await client.set(`session:${sessionId}`, JSON.stringify(sessionData), 'EX', SESSION_TTL);
return sessionId;
}
async function getSession(client: Redis, sessionId: string): Promise<object | null> {
const data = await client.get(`session:${sessionId}`);
if (!data) return null;
return JSON.parse(data);
}
async function deleteSession(client: Redis, sessionId: string) {
await client.del(`session:${sessionId}`);
}
async function updateSession(client: Redis, sessionId: string, data: object) {
const ttl = await client.ttl(`session:${sessionId}`);
if (ttl > 0) {
await client.set(`session:${sessionId}`, JSON.stringify(data), 'EX', ttl);
}
}
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"time"
)
const sessionTTL = 30 * time.Minute
func createSession(ctx context.Context, client *redis.Client, userID string, data map[string]interface{}) (string, error) {
bytes := make([]byte, 32)
rand.Read(bytes)
sessionID := base64.URLEncoding.EncodeToString(bytes)
data["user_id"] = userID
jsonData, _ := json.Marshal(data)
err := client.Set(ctx, "session:"+sessionID, jsonData, sessionTTL).Err()
return sessionID, err
}
func getSession(ctx context.Context, client *redis.Client, sessionID string) (map[string]interface{}, error) {
data, err := client.Get(ctx, "session:"+sessionID).Result()
if err == redis.Nil {
return nil, nil
}
var result map[string]interface{}
json.Unmarshal([]byte(data), &result)
return result, err
}
func deleteSession(ctx context.Context, client *redis.Client, sessionID string) {
client.Del(ctx, "session:"+sessionID)
}
func updateSession(ctx context.Context, client *redis.Client, sessionID string, data map[string]interface{}) {
ttl, _ := client.TTL(ctx, "session:"+sessionID).Result()
if ttl > 0 {
jsonData, _ := json.Marshal(data)
client.Set(ctx, "session:"+sessionID, jsonData, ttl)
}
}
Storing sessions as structured fields
When you need to read or update individual session fields without touching the entire session, Redis hashes are a better fit. Each session is a hash where fields map to values. You can get or set specific fields with HGET and HSET, or fetch everything with HGETALL.
Hashes work well for sessions with many fields or when different parts of your application update different fields. A shopping cart module updates cart_items while an authentication module updates last_activity. Neither needs to serialize and deserialize the entire session. You can also inspect session contents directly in Redis using HGETALL, which helps with debugging.
The tradeoff is that hash values are strings, so nested objects still require serialization. If your session contains a complex preferences object, you either serialize it into one hash field or flatten it into multiple fields. Hashes also require explicit expiration management since HSET does not accept TTL directly.
- Redis
- Python
- TypeScript
- Go
# Create a session as a hash
HSET session:abc123 user_id "user789" role "admin" cart_items "3" created_at "1699900000"
> 4 (fields set)
# Set expiration on the hash key
EXPIRE session:abc123 1800
> 1
# Get a single field
HGET session:abc123 user_id
> "user789"
# Get multiple fields
HMGET session:abc123 user_id role
> ["user789", "admin"]
# Update one field without affecting others
HSET session:abc123 cart_items "5"
> 0 (field updated, not created)
# Get all session data
HGETALL session:abc123
> {"user_id": "user789", "role": "admin", "cart_items": "5", "created_at": "1699900000"}
# Increment a numeric field
HINCRBY session:abc123 cart_items 1
> 6
import secrets
import time
SESSION_TTL = 1800
def create_session_hash(client, user_id: str, data: dict) -> str:
"""Create a session using a hash."""
session_id = secrets.token_urlsafe(32)
key = f'session:{session_id}'
session_data = {'user_id': user_id, 'created_at': str(int(time.time())), **data}
client.hset(key, mapping=session_data)
client.expire(key, SESSION_TTL)
return session_id
def get_session_field(client, session_id: str, field: str) -> str | None:
"""Get a single session field."""
return client.hget(f'session:{session_id}', field)
def get_session_hash(client, session_id: str) -> dict | None:
"""Get all session data."""
data = client.hgetall(f'session:{session_id}')
return data if data else None
def set_session_field(client, session_id: str, field: str, value: str):
"""Update a single session field."""
client.hset(f'session:{session_id}', field, value)
def increment_session_field(client, session_id: str, field: str, amount: int = 1) -> int:
"""Increment a numeric session field."""
return client.hincrby(f'session:{session_id}', field, amount)
import { randomBytes } from 'crypto';
const SESSION_TTL = 1800;
async function createSessionHash(client: Redis, userId: string, data: Record<string, string>): Promise<string> {
const sessionId = randomBytes(32).toString('base64url');
const key = `session:${sessionId}`;
const sessionData = { user_id: userId, created_at: String(Math.floor(Date.now() / 1000)), ...data };
await client.hset(key, sessionData);
await client.expire(key, SESSION_TTL);
return sessionId;
}
async function getSessionField(client: Redis, sessionId: string, field: string): Promise<string | null> {
return await client.hget(`session:${sessionId}`, field);
}
async function getSessionHash(client: Redis, sessionId: string): Promise<Record<string, string> | null> {
const data = await client.hgetall(`session:${sessionId}`);
return Object.keys(data).length > 0 ? data : null;
}
async function setSessionField(client: Redis, sessionId: string, field: string, value: string) {
await client.hset(`session:${sessionId}`, field, value);
}
async function incrementSessionField(client: Redis, sessionId: string, field: string, amount: number = 1): Promise<number> {
return await client.hincrby(`session:${sessionId}`, field, amount);
}
func createSessionHash(ctx context.Context, client *redis.Client, userID string, data map[string]string) (string, error) {
bytes := make([]byte, 32)
rand.Read(bytes)
sessionID := base64.URLEncoding.EncodeToString(bytes)
key := "session:" + sessionID
data["user_id"] = userID
data["created_at"] = fmt.Sprintf("%d", time.Now().Unix())
client.HSet(ctx, key, data)
client.Expire(ctx, key, sessionTTL)
return sessionID, nil
}
func getSessionField(ctx context.Context, client *redis.Client, sessionID, field string) (string, error) {
return client.HGet(ctx, "session:"+sessionID, field).Result()
}
func getSessionHash(ctx context.Context, client *redis.Client, sessionID string) (map[string]string, error) {
return client.HGetAll(ctx, "session:"+sessionID).Result()
}
func setSessionField(ctx context.Context, client *redis.Client, sessionID, field, value string) {
client.HSet(ctx, "session:"+sessionID, field, value)
}
func incrementSessionField(ctx context.Context, client *redis.Client, sessionID, field string, amount int64) (int64, error) {
return client.HIncrBy(ctx, "session:"+sessionID, field, amount).Result()
}
Keeping active sessions alive with sliding expiration
Fixed expiration deletes sessions after a set time regardless of activity. A 30-minute session expires 30 minutes after login even if the user is actively clicking around. Sliding expiration resets the TTL on each request, so sessions only expire after 30 minutes of inactivity. Active users stay logged in indefinitely.
Implement sliding expiration by calling EXPIRE with the full TTL duration on every request that touches the session. This is cheap: EXPIRE is O(1) and adds negligible latency. Combine it with either the string or hash approach depending on your session structure needs.
The tradeoff is that very active users never log out automatically. For security-sensitive applications, you might want both a sliding expiration for inactivity and an absolute maximum session lifetime. Track the creation timestamp in the session and enforce a hard limit regardless of activity. Financial applications often require re-authentication after a few hours even with continuous use.
- Redis
- Python
- TypeScript
- Go
# On each request, refresh the session TTL
EXPIRE session:abc123 1800
> 1 (TTL reset to 30 minutes)
# Combine with GET in a pipeline for efficiency
GET session:abc123
EXPIRE session:abc123 1800
# For hash sessions, same approach
HGETALL session:abc123
EXPIRE session:abc123 1800
# Check when session was created (for absolute timeout)
HGET session:abc123 created_at
> "1699900000"
# If now - created_at > max_lifetime, force logout
import time
SESSION_TTL = 1800 # 30 minutes sliding
MAX_SESSION_LIFETIME = 28800 # 8 hours absolute
def get_session_with_refresh(client, session_id: str) -> dict | None:
"""Get session data and refresh TTL (sliding expiration)."""
key = f'session:{session_id}'
pipe = client.pipeline()
pipe.hgetall(key)
pipe.expire(key, SESSION_TTL)
results = pipe.execute()
data = results[0]
if not data:
return None
# Check absolute timeout
created_at = int(data.get('created_at', 0))
if time.time() - created_at > MAX_SESSION_LIFETIME:
client.delete(key)
return None
return data
def touch_session(client, session_id: str) -> bool:
"""Refresh session TTL without fetching data."""
return client.expire(f'session:{session_id}', SESSION_TTL)
const SESSION_TTL = 1800; // 30 minutes sliding
const MAX_SESSION_LIFETIME = 28800; // 8 hours absolute
async function getSessionWithRefresh(client: Redis, sessionId: string): Promise<Record<string, string> | null> {
const key = `session:${sessionId}`;
const pipeline = client.pipeline();
pipeline.hgetall(key);
pipeline.expire(key, SESSION_TTL);
const results = await pipeline.exec();
const data = results[0][1] as Record<string, string>;
if (!data || Object.keys(data).length === 0) return null;
// Check absolute timeout
const createdAt = parseInt(data.created_at || '0');
if (Date.now() / 1000 - createdAt > MAX_SESSION_LIFETIME) {
await client.del(key);
return null;
}
return data;
}
async function touchSession(client: Redis, sessionId: string): Promise<boolean> {
const result = await client.expire(`session:${sessionId}`, SESSION_TTL);
return result === 1;
}
const sessionTTL = 30 * time.Minute
const maxSessionLifetime = 8 * time.Hour
func getSessionWithRefresh(ctx context.Context, client *redis.Client, sessionID string) (map[string]string, error) {
key := "session:" + sessionID
pipe := client.Pipeline()
dataCmd := pipe.HGetAll(ctx, key)
pipe.Expire(ctx, key, sessionTTL)
pipe.Exec(ctx)
data, err := dataCmd.Result()
if err != nil || len(data) == 0 {
return nil, err
}
// Check absolute timeout
createdAt, _ := strconv.ParseInt(data["created_at"], 10, 64)
if time.Now().Unix()-createdAt > int64(maxSessionLifetime.Seconds()) {
client.Del(ctx, key)
return nil, nil
}
return data, nil
}
func touchSession(ctx context.Context, client *redis.Client, sessionID string) bool {
result, _ := client.Expire(ctx, "session:"+sessionID, sessionTTL).Result()
return result
}
Choosing an approach
Use basic session storage with strings when your session data is simple, you always read and write the entire session, and your web framework already handles serialization. This is the most common approach and works well for most applications.
Use structured sessions with hashes when different parts of your application update different session fields independently, when you want to inspect sessions directly in Redis, or when you have many fields and want to avoid serializing the entire session on every update.
Use sliding expiration when you want sessions to persist as long as users are active. Combine it with an absolute maximum lifetime for security-sensitive applications. The EXPIRE call adds negligible overhead and can be pipelined with your session read.
All three approaches scale well. Redis handles millions of sessions without issue, and lookups are sub-millisecond. The main failure mode is Redis unavailability: if Redis goes down, all sessions are lost. For critical applications, configure Redis persistence or use Redis Cluster for replication.