August 10, 2026
Secure and Stateful Session Management with Redis in APIs
In modern web applications, authentication is commonly handled using JWT (JSON Web Token). Its stateless nature is appealing — no…

By Baki Turhan
3 min read
In modern web applications, authentication is commonly handled using JWT (JSON Web Token). Its stateless nature is appealing — no server-side state means easier horizontal scaling. But this convenience comes at a cost: a JWT cannot be revoked by the server before it expires.
When a user's account is compromised, a suspicious device is detected, or a "logout from all devices" action is triggered — stateless JWT has no answer. Control resides entirely on the client side.
This post explores how Redis can be used to fill those gaps — building a genuinely controllable, stateful session architecture without abandoning JWT entirely.
Why Redis?
The core idea is straightforward: a short-lived (e.g. 15-minute) acces_token is issued as a JWT to the client. The long-lived refresh_token, however, is stored server-side in Redis.
This distinction is critical. When the refresh token lives on the server, any session can be deleted, restricted, or inspected at will. Control shifts back to the server.
Redis is preferred for this role because of performance. Querying a disk-based relational database on every authentication request introduces unnecessary overhead. Redis, being in-memory, responds in milliseconds and eliminates this bottleneck.
Brute-Force Protection
The authentication flow begins with a guard layer, before any user credentials are validated. The server queries a failed-login counter stored in Redis:
locked, err := RedisClient.Get(ctx, lockKey).Result()
if err != redis.Nil && locked != "" {
return errors.New("account is temporarily locked due to too many failed login attempts")
}locked, err := RedisClient.Get(ctx, lockKey).Result()
if err != redis.Nil && locked != "" {
return errors.New("account is temporarily locked due to too many failed login attempts")
}Each failed attempt increments a counter. Once a threshold is reached, the account is temporarily locked and a lock key is written to Redis. On a successful login, both keys are cleared. This approach stops brute-force attacks at the Redis layer — without a single database query.
Session Data Model
The session object stored in Redis is not just a token. It carries complete context for each individual session:
type Session struct {
ID string // Unique session identifier (UUID)
UserID uint // User identifier
RefreshTokenHash string // SHA-256 hashed token
IP string // Request origin IP
UserAgent string // Browser and device info
DeviceName string // Parsed device name
CreatedAt time.Time // Session start time
LastSeenAt time.Time // Last active time
ExpiresAt time.Time // Expiry time
}type Session struct {
ID string // Unique session identifier (UUID)
UserID uint // User identifier
RefreshTokenHash string // SHA-256 hashed token
IP string // Request origin IP
UserAgent string // Browser and device info
DeviceName string // Parsed device name
CreatedAt time.Time // Session start time
LastSeenAt time.Time // Last active time
ExpiresAt time.Time // Expiry time
}The most critical field is RefreshTokenHash. The refresh token is never stored as plain text — it is hashed with SHA-256 and only the digest is kept. Even if the Redis server is fully compromised, raw tokens remain inaccessible.
The IP and UserAgent fields serve a different purpose: they are the foundation of suspicious activity detection.
Concurrent Device Limit
The number of concurrent active sessions per user can be bounded. When a new login occurs, the existing session count is checked. If the limit is exceeded, the session with the oldest LastSeenAt value is automatically terminated:
// Called before creating the new session
enforceDeviceLimit(userID)// Called before creating the new session
enforceDeviceLimit(userID)This mechanism both strengthens security and ensures stale sessions from unauthorized devices are cleaned up automatically.
Token Rotation and Theft Detection
When a new access token is requested using a refresh token, the system runs two critical checks.
Token Hash Validation
The incoming token is hashed and compared against the stored hash in Redis. If they don't match, the token has either been used before or was tampered with:
if session.RefreshTokenHash != HashToken(req.RefreshToken) {
DeleteSession(userID, sessionID)
return errors.New("token reuse detected: session has been revoked")
}if session.RefreshTokenHash != HashToken(req.RefreshToken) {
DeleteSession(userID, sessionID)
return errors.New("token reuse detected: session has been revoked")
}On every use, the old token is invalidated and replaced with a new one (rotation). If a stolen token is reused, the system detects it and terminates the session immediately.
Device Change Detection
Even if the token is valid, a changed UserAgent marks the session as suspicious:
if session.UserAgent != userAgent {
LogSuspiciousActivity(userID, sessionID, "Device change detected")
DeleteSession(userID, sessionID)
return errors.New("suspicious session activity detected: session revoked")
}if session.UserAgent != userAgent {
LogSuspiciousActivity(userID, sessionID, "Device change detected")
DeleteSession(userID, sessionID)
return errors.New("suspicious session activity detected: session revoked")
}An IP change is treated more leniently: the session is not terminated, but a suspicious activity log is recorded. This distinction matters because mobile users frequently switch IPs.
Session Lifetime: Sliding and Absolute Expiration
Two expiration strategies work in tandem.
Sliding Expiration: As long as the user remains active, the TTL in Redis is extended on every token refresh. Active users are not forced to re-authenticate unnecessarily.
Absolute Expiration: Left alone, sliding expiration could theoretically keep a session alive indefinitely. To prevent this, a hard limit is enforced based on the session's original CreatedAt time:
absoluteMaxTime := session.CreatedAt.Add(30 * 24 * time.Hour)
if time.Now().After(absoluteMaxTime) {
DeleteSession(userID, sessionID)
return errors.New("absolute session duration expired, please login again")
}
// If remaining time is less than the sliding duration, use what's left
newTTL := min(sessionDuration, time.Until(absoluteMaxTime))absoluteMaxTime := session.CreatedAt.Add(30 * 24 * time.Hour)
if time.Now().After(absoluteMaxTime) {
DeleteSession(userID, sessionID)
return errors.New("absolute session duration expired, please login again")
}
// If remaining time is less than the sliding duration, use what's left
newTTL := min(sessionDuration, time.Until(absoluteMaxTime))No matter how active a user is, once 30 days have passed from the session's creation, re-authentication becomes mandatory.
Each of these layers may seem like a minor improvement in isolation. But brute-force protection, device limits, token rotation, suspicious activity detection, and absolute session expiration — when designed together — produce an authentication layer that is genuinely robust and controllable.