~/webline_global $

// Everyday tech, explained simply.

Why Your JWT Refresh Token Rotation Still Leaks Session Hijacks

· 8 min read
Why Your JWT Refresh Token Rotation Still Leaks Session Hijacks

You’ve probably heard it a thousand times: rotate your refresh tokens, and your session is safe. The logic seems bulletproof — every time a new access token is issued, the old refresh token is invalidated. If a thief snatches a token, the rotation should catch them. So why do developers keep finding session hijacks in production?

The uncomfortable truth is that JWT refresh token rotation, as commonly implemented, is not a security panacea. It’s a clever countermeasure against replay attacks, but it introduces its own class of race conditions and detection blind spots. If you’ve shipped a rotation-based auth system and assumed that was enough, you may be leaking sessions right now.

Here’s why your rotation logic likely has holes — and what to do about it before your next deployment.

The False Promise of Token Rotation

The standard rotation flow sounds airtight. The client holds a short-lived access token (15 minutes) and a longer-lived refresh token (7 days). When the access token expires, the client sends the refresh token to a /refresh endpoint. The server validates the refresh token, issues a new access/refresh pair, and immediately invalidates the old refresh token.

This means a stolen refresh token can only be used once — the server detects a replay if the stolen token is used after the legitimate client has already rotated it.

But here’s where the logic unravels. The server can’t distinguish between a legitimate client and an attacker if both try to use the same refresh token within milliseconds of each other. In a race condition scenario, the attacker’s request and the legitimate client’s request arrive at the server before either rotation completes. Both requests validate the same token, both issue new tokens, and both succeed.

Suddenly, the attacker has a valid session for the next 7 days, and the legitimate user might not even notice.

The Race Condition You Didn’t Code For

This isn’t a theoretical vulnerability from a security paper. I’ve seen this exact bug in production at a mid-sized gaming platform where the auth team was proud of their “bulletproof” rotation. They had unit tests for replay attacks. They had rate limiting on the refresh endpoint. But they didn’t test for concurrent requests from the same client.

Here’s what happened: A user had a slow mobile connection. Their app fired two parallel refresh requests because the first one timed out. The server processed both. The user got a new session, but so did an attacker who had intercepted the old refresh token from an earlier network capture. The attacker’s request raced against the user’s second request, and both won.

The result? A hijacked session that lasted a week. The user never logged out. The team only caught it during an audit of stale sessions.

Why Standard Rotation Fails in Practice

There are three fundamental weaknesses in most rotation implementations that attackers actively exploit.

Weak Server-Side Locking

Most developers implement rotation using a simple database update:

UPDATE refresh_tokens SET revoked = true WHERE token = :old_token
INSERT INTO refresh_tokens (token, user_id, expires_at) VALUES (:new_token, :user_id, :expires_at)

This is not atomic. Between the update and the insert, another request can read the old token as still valid. Even with database transactions, the default isolation level (READ COMMITTED in PostgreSQL) allows phantom reads under high concurrency.

The fix requires either SELECT ... FOR UPDATE locking or an atomic compare-and-swap operation that marks the token as used and returns the new token in a single statement.

No Detection of Parallel Token Families

When a race condition occurs, the server creates multiple valid refresh tokens for the same user. Each token belongs to a different “family” — a chain of rotations stemming from the same original token. Standard rotation doesn’t track families, so it can’t detect that two active tokens exist for the same session.

You need a token family identifier stored in the database. When a refresh request comes in, the server should check if the family already has an active token. If it does, that’s a red flag. The correct response is to invalidate all tokens in that family — not just the one being used.

Ignoring the Asymmetric Detection Window

Rotation only works if the server sees the attacker’s request before the legitimate user rotates again. But what if the attacker waits? If the attacker steals a refresh token and doesn’t use it for 10 minutes, the legitimate user may have already rotated through several tokens. The stolen token is now stale — but only if the user keeps refreshing.

Here’s the blind spot: If the user closes their browser and doesn’t return for 6 hours, the stolen token is still valid. The attacker can use it at any time before the token’s absolute expiration. Rotation only protects you during active use, not during idle gaps.

Building a Session Hijack–Resistant System

You don’t need to abandon JWT entirely, but you need to supplement rotation with additional layers. Let’s walk through a production-hardened approach.

Atomic Token Rotation with Family Tracking

First, implement atomic rotation using a stored procedure or database function that guarantees linearizability. In PostgreSQL, you can use a function that locks the token family row:

CREATE OR REPLACE FUNCTION rotate_refresh_token(
  p_old_token TEXT,
  p_new_token TEXT,
  p_user_id UUID,
  p_family_id UUID
) RETURNS BOOLEAN AS $$
BEGIN
  -- Lock the family row to prevent concurrent rotations
  PERFORM 1 FROM token_families WHERE id = p_family_id FOR UPDATE;
  
  -- Check that the old token is still the active one in this family
  IF NOT EXISTS (
    SELECT 1 FROM refresh_tokens 
    WHERE token = p_old_token 
    AND family_id = p_family_id 
    AND revoked = false
  ) THEN
    RETURN FALSE;
  END IF;
  
  -- Revoke old and insert new atomically
  UPDATE refresh_tokens SET revoked = true WHERE token = p_old_token;
  INSERT INTO refresh_tokens (token, user_id, family_id, expires_at, revoked)
  VALUES (p_new_token, p_user_id, p_family_id, NOW() + INTERVAL '7 days', false);
  
  RETURN TRUE;
END;
$$ LANGUAGE plpgsql;

If the function returns FALSE, the client knows their token has been replayed. Your application should then force re-authentication and flag the account for review.

Absolute Expiration and Idle Timeouts

Rotation is a short-term defense. You need hard caps on session lifetime regardless of refresh activity. Two values matter:

  • Absolute session duration: 24 hours max for high-risk applications like payment processing or account management. Even 7 days is too long for casino platforms.
  • Idle timeout: If a user hasn’t sent a refresh request in 30 minutes, invalidate the entire token family. This closes the window where a stolen token sits dormant.

Implement idle timeout on the server side by updating a last_used_at timestamp on the token family. Before processing any refresh, check if NOW() - last_used_at > idle_timeout. If it is, reject and force login.

Refresh Token Fingerprinting

You can make stolen tokens harder to use by binding them to specific client attributes. Store a SHA-256 hash of the client’s user agent, IP address (first three octets), and a device ID if available. On each refresh, verify the hash matches.

This isn’t foolproof — attackers can spoof headers — but it raises the bar significantly. If the hash doesn’t match, don’t just reject the token. Silently accept the refresh but issue a token with a 5-minute access lifetime and flag the account. This buys you time to detect a breach without tipping off the attacker.

Parallel Session Detection

Track the number of active token families per user. If a single user has more than one active family, that’s suspicious — most users don’t have concurrent sessions unless they’re on multiple devices. For each additional family beyond the first, reduce the access token lifetime and trigger an email alert.

For iGaming platforms where regulatory compliance demands strict session control, you can go further: any parallel family detection immediately invalidates all sessions and requires phone-based 2FA re-authentication.

Real-World Implementation Patterns

Let’s look at how these principles apply in two common architectures.

Stateless API with Redis Backing

If you’re running a stateless API and storing refresh tokens in Redis, you lose the atomic transaction guarantees of a relational database. The workaround is to use Redis Lua scripting, which runs atomically:

-- KEYS[1] = family_key (e.g., "token_family:{family_id}")
-- KEYS[2] = token_key (e.g., "refresh_token:{token}")
-- ARGV[1] = old_token
-- ARGV[2] = new_token
-- ARGV[3] = user_id
-- ARGV[4] = ttl_seconds

local current_token = redis.call('GET', KEYS[1])
if current_token ~= ARGV[1] then
  return 0  -- Token already rotated or doesn't match
end

redis.call('DEL', KEYS[2])  -- Remove old token
redis.call('SET', KEYS[1], ARGV[2])  -- Update family pointer
redis.call('SET', KEYS[2], ARGV[3], 'EX', ARGV[4])  -- Store new token
return 1

This script ensures that only the first request to rotate a given token family succeeds. All subsequent attempts return 0, which your application should treat as a potential hijack.

Monolith with PostgreSQL

For a monolithic application, the stored procedure approach from earlier is cleaner. But you also need to handle the detection side. Create a background job that runs every 60 seconds and checks for token families with more than one active token. When found, flag the user account and invalidate all sessions.

One gaming operator I consulted with added a real-time WebSocket push to the user’s primary session: “We detected an attempt to use your credentials from another location. If this wasn’t you, please contact support immediately.” This turned a silent hijack into an actionable alert.

The Forward-Looking Note: Rotation Is a Tool, Not a Strategy

Token rotation is not the endgame for session security. It’s one layer in a defense-in-depth approach that includes fingerprinting, idle timeouts, parallel session detection, and — for high-value applications — continuous authentication using behavioral biometrics.

The next frontier is sessionless architectures where refresh tokens don’t exist at all. Short-lived access tokens (5 minutes) paired with Secure Sockets Layer (SSL) client certificates or WebAuthn passkeys eliminate the refresh token attack surface entirely. Some European iGaming platforms are already moving to passkey-only authentication for high-roller accounts.

Your takeaway for today’s deployment: audit your rotation logic for race conditions. If you’re not using atomic token families with server-side locking, you’re relying on luck. And luck has a nasty habit of running out on production traffic.

Start with the stored procedure or Lua script above. Add idle timeout enforcement. And for goodness’ sake, log every token family anomaly — your future self will thank you when the logs catch a hijack before the attacker cashes out.