DEV Community

ahmet gedik
ahmet gedik

Posted on

Building JWT Refresh Token Rotation for Multi-Region Video API Clients

The leak that made long-lived tokens unacceptable

A partner integration for our discovery API shipped a mobile SDK that cached its access token in plain SharedPreferences. The token was a 30-day JWT. When someone pulled that token off a rooted device and started replaying it against our /v1/trending?region=DE endpoint from a datacenter in another country, we had no way to tell the difference between the real client and the attacker. Both presented a perfectly valid, correctly-signed token. Revocation meant rotating the signing key, which would have logged out every legitimate client on the platform at once.

That incident is why we rebuilt authentication around short-lived access tokens plus rotating refresh tokens with reuse detection. This post is the actual design we run in production at TrendVidStream, where the API serves streaming-availability data across eight regions from a PHP 8.4 backend on SQLite with an FTP-based deploy pipeline. I'll walk through the token model, the rotation and reuse-detection logic, the SQLite schema, and client-side handling in Python and Go — all runnable, not pseudocode.

The core idea is simple to state and easy to get wrong: every time a client uses a refresh token, it gets a brand-new refresh token and the old one is retired. If a retired token is ever presented again, the entire token family is nuked. That single invariant turns a stolen refresh token from a permanent backdoor into a detectable, self-limiting event.

Why rotation, and what problem it actually solves

Static long-lived tokens have two failure modes. If the access token is long-lived, a leak grants long-lived access. If the access token is short-lived but the refresh token is a static bearer credential, then whoever holds the refresh token can mint access tokens forever — you've just moved the long-lived secret one layer down.

Rotation attacks both problems:

  • Access tokens stay short (we use 15 minutes). A stolen access token expires before most attackers finish tooling up.
  • Refresh tokens are single-use. Each refresh call invalidates the presented token and issues a new one. A leaked refresh token is only useful until either the attacker or the legitimate client uses it once.
  • Reuse is a signal, not an error. In a correctly-behaving system a refresh token is presented exactly once. A second presentation means two parties hold the same token — a fork — which almost always means theft. We treat that as a compromise event and revoke the whole family.

That last point is the one people skip. Rotation without reuse detection still leaks: if the attacker refreshes before the victim, the attacker silently takes over the family and the victim just gets logged out with no alarm. Reuse detection is what makes the theft loud.

The token family model

We don't track individual refresh tokens in isolation. We track families. A family is created when a user logs in fresh (password, OAuth, whatever your primary auth is). Every rotation within that login session belongs to the same family and carries a monotonically increasing generation counter.

  • family_id — one per login session, stable across rotations.
  • token_id (jti) — unique per refresh token, changes on every rotation.
  • generation — increments each rotation; useful for debugging and analytics.
  • used_at — null until the token is redeemed, then stamped.
  • revoked — family-wide kill switch.

When a reused token is detected, we revoke by family_id, not by token_id. That kills the attacker's stolen branch and the victim's legitimate branch, forcing a clean re-login. Better a forced re-login than a silent hijack.

Here is the SQLite schema. Note the FTS5 line is elsewhere in our codebase for search — auth uses plain indexed tables, because auth queries are point lookups by token_id, not full-text.

CREATE TABLE IF NOT EXISTS refresh_tokens (
    token_id     TEXT PRIMARY KEY,        -- jti, random 256-bit
    family_id    TEXT NOT NULL,           -- stable per login session
    user_id      INTEGER NOT NULL,
    token_hash   TEXT NOT NULL,           -- sha256 of the raw secret
    generation   INTEGER NOT NULL DEFAULT 0,
    issued_at    INTEGER NOT NULL,        -- unix seconds
    expires_at   INTEGER NOT NULL,
    used_at      INTEGER,                 -- null until redeemed
    revoked      INTEGER NOT NULL DEFAULT 0,
    client_ip    TEXT,
    user_agent   TEXT
);

CREATE INDEX IF NOT EXISTS idx_rt_family  ON refresh_tokens(family_id);
CREATE INDEX IF NOT EXISTS idx_rt_user    ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_rt_expires ON refresh_tokens(expires_at);
Enter fullscreen mode Exit fullscreen mode

We store a SHA-256 hash of the refresh secret, never the raw value. The raw secret only ever exists in transit and in the client's secure storage. If our SQLite file is exfiltrated — and it's a single file on disk, so that's a real threat model — the attacker gets hashes, not usable tokens. The access-token signing key lives outside the database entirely, in an environment variable loaded at boot.

Issuing the pair

Access tokens are stateless JWTs signed with HS256 (a single backend, symmetric key, no need for RSA's overhead here). Refresh tokens are opaque — random bytes, not JWTs. There is no reason to make a refresh token a JWT: it's a database-backed handle, and encoding claims into it just invites people to trust those claims instead of the row.

<?php
declare(strict_types=1);

final class TokenService
{
    private const ACCESS_TTL  = 900;        // 15 minutes
    private const REFRESH_TTL  = 60 * 86400; // 60 days

    public function __construct(
        private readonly PDO $db,
        private readonly string $signingKey
    ) {}

    /** Fresh login: new family, generation 0. */
    public function issuePair(int $userId, string $ip, string $ua): array
    {
        $familyId = bin2hex(random_bytes(16));
        return $this->mint($userId, $familyId, 0, $ip, $ua);
    }

    private function mint(int $userId, string $familyId, int $gen, string $ip, string $ua): array
    {
        $now      = time();
        $tokenId  = bin2hex(random_bytes(16));
        $secret   = bin2hex(random_bytes(32));            // the raw refresh secret
        $hash     = hash('sha256', $secret);

        $stmt = $this->db->prepare(
            'INSERT INTO refresh_tokens
             (token_id, family_id, user_id, token_hash, generation,
              issued_at, expires_at, client_ip, user_agent)
             VALUES (?,?,?,?,?,?,?,?,?)'
        );
        $stmt->execute([
            $tokenId, $familyId, $userId, $hash, $gen,
            $now, $now + self::REFRESH_TTL, $ip, $ua,
        ]);

        return [
            'access_token'  => $this->signAccess($userId, $now),
            'refresh_token' => $tokenId . '.' . $secret,   // client stores this whole string
            'expires_in'    => self::ACCESS_TTL,
        ];
    }

    private function signAccess(int $userId, int $now): string
    {
        $header  = $this->b64(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
        $payload = $this->b64(json_encode([
            'sub' => $userId,
            'iat' => $now,
            'exp' => $now + self::ACCESS_TTL,
            'iss' => 'trendvidstream',
        ]));
        $sig = hash_hmac('sha256', "$header.$payload", $this->signingKey, true);
        return "$header.$payload." . $this->b64($sig);
    }

    private function b64(string $data): string
    {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }
}
Enter fullscreen mode Exit fullscreen mode

The refresh token handed to the client is token_id.secret. The token_id is the lookup key (indexed, fast), and the secret is the thing we hash-compare. Splitting them means the database query is a primary-key point lookup, and the secret comparison is a constant-time check against the stored hash — you never do a table scan on secrets.

Rotation and reuse detection: the heart of it

This is the method that gets called on every /v1/auth/refresh. Read the ordering carefully, because the ordering is the security property.

<?php
declare(strict_types=1);

final class RefreshResult
{
    public function __construct(
        public readonly bool $ok,
        public readonly ?array $tokens = null,
        public readonly ?string $reason = null
    ) {}
}

trait RotatesTokens
{
    public function refresh(string $presented, string $ip, string $ua): RefreshResult
    {
        [$tokenId, $secret] = array_pad(explode('.', $presented, 2), 2, '');
        if ($tokenId === '' || $secret === '') {
            return new RefreshResult(false, reason: 'malformed');
        }

        $this->db->beginTransaction();
        try {
            $row = $this->lockRow($tokenId);

            if ($row === null) {
                $this->db->commit();
                return new RefreshResult(false, reason: 'unknown');
            }

            // Constant-time secret check BEFORE any state change.
            if (!hash_equals($row['token_hash'], hash('sha256', $secret))) {
                $this->db->commit();
                return new RefreshResult(false, reason: 'bad_secret');
            }

            // REUSE DETECTED: this token was already redeemed once.
            // Someone forked the family. Burn the whole family down.
            if ($row['used_at'] !== null) {
                $this->revokeFamily((string) $row['family_id']);
                $this->db->commit();
                return new RefreshResult(false, reason: 'reuse_detected');
            }

            if ((int) $row['revoked'] === 1 || (int) $row['expires_at'] < time()) {
                $this->db->commit();
                return new RefreshResult(false, reason: 'revoked_or_expired');
            }

            // Mark the presented token used, then mint the next generation.
            $this->markUsed($tokenId);
            $tokens = $this->mint(
                (int) $row['user_id'],
                (string) $row['family_id'],
                (int) $row['generation'] + 1,
                $ip, $ua
            );

            $this->db->commit();
            return new RefreshResult(true, tokens: $tokens);
        } catch (\Throwable $e) {
            $this->db->rollBack();
            throw $e;
        }
    }

    private function lockRow(string $tokenId): ?array
    {
        // SQLite: the surrounding BEGIN IMMEDIATE / write intent serializes
        // concurrent refreshes of the same row.
        $stmt = $this->db->prepare('SELECT * FROM refresh_tokens WHERE token_id = ?');
        $stmt->execute([$tokenId]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ?: null;
    }

    private function markUsed(string $tokenId): void
    {
        $this->db->prepare('UPDATE refresh_tokens SET used_at = ? WHERE token_id = ?')
                 ->execute([time(), $tokenId]);
    }

    private function revokeFamily(string $familyId): void
    {
        $this->db->prepare('UPDATE refresh_tokens SET revoked = 1 WHERE family_id = ?')
                 ->execute([$familyId]);
        error_log("auth: reuse detected, family revoked: $familyId");
    }
}
Enter fullscreen mode Exit fullscreen mode

Things that matter in that code, in order of how often people get them wrong:

  • The secret check happens before the reuse check. If you check used_at first and return a reuse error for any known token_id, an attacker who only knows a token_id (which is not secret — it's a lookup key) can force-revoke arbitrary families. Verify the secret first, so only someone holding the real token can trigger reuse detection.
  • hash_equals for the comparison. A plain === on hashes leaks timing. It's cheap insurance.
  • The whole thing is one transaction with a locked row. SQLite serializes writes at the database level, so two concurrent refreshes of the same token can't both win. With Postgres you'd use SELECT ... FOR UPDATE; the property you need is that exactly one of two racing refreshes redeems the token and the other sees used_at already set.
  • Reuse revokes the family, then returns a failure. The client that triggered reuse detection — attacker or victim — gets nothing. Both are forced to re-authenticate. That's the correct, if slightly brutal, outcome.

The concurrency edge case nobody warns you about

Here's the trap. A real mobile client fires two API calls at once, both get a 401 because the access token just expired, and both independently call /refresh with the same refresh token. That's not an attack — it's a normal race. If you naively treat the second call as reuse, you log out legitimate users constantly and they hate you.

We handle it with a short grace window. When a token is redeemed, we don't just mark it used — we record the token_id of its successor. If a redeemed token is presented again within a few seconds and its successor hasn't itself been used, we return the already-minted successor instead of screaming reuse. Outside that window, or if the successor was also consumed, it's a genuine fork and we revoke.

The practical version: add a replaced_by TEXT column, and in the reuse branch check whether the replacement was issued within, say, 10 seconds and is still unused. If so, re-hand the same new pair. This collapses the double-refresh race without weakening detection of real theft, which never looks like two calls 200ms apart from the same IP and user agent.

Multi-region considerations

We deploy the same PHP codebase to eight regional edges via FTP automation, each with its own SQLite file that carries auth state for clients homed to that region. That architecture forces a few decisions:

  • Clock skew. Access tokens are validated at whichever edge the request lands on. We give exp a 30-second leeway on validation because regional clocks drift, and 15-minute access tokens make that leeway negligible. Never validate exp with zero tolerance across regions — you'll reject valid tokens at the boundary.
  • Refresh is region-pinned. A refresh token issued in eu-central is redeemed in eu-central. We route /auth/refresh by the region claim baked into the login response, so the single-writer SQLite file never has to be replicated for auth. This sidesteps the hardest distributed problem — a refresh token redeemed in two regions at once — by never allowing it.
  • Deploy safety. Because deploys are FTP file pushes, the signing key is never in the deployed tree. It's an environment variable on each edge. A botched deploy can't leak it, and rotating it is a config change, not a code change.
  • Cleanup runs per-region cron. Expired and long-revoked rows are pruned nightly so the auth table stays small and the index stays hot.
-- nightly cron per region: prune dead rows, keep the table lean
DELETE FROM refresh_tokens
WHERE (expires_at < strftime('%s','now') - 604800)   -- 7 days past expiry
   OR (revoked = 1 AND issued_at < strftime('%s','now') - 604800);
Enter fullscreen mode Exit fullscreen mode

Client-side rotation handling

The client is where rotation quietly breaks if you're careless, because the client must persist the new refresh token atomically before making the retried request. Drop the new token after redeeming the old one and you've locked yourself out — the old one is now used, the new one is lost.

Here's a Python client that refreshes on 401, persists the rotated token, and retries exactly once:

import json
import time
import threading
import requests

class VideoApiClient:
    def __init__(self, base_url, token_path):
        self.base_url = base_url.rstrip('/')
        self.token_path = token_path
        self._lock = threading.Lock()
        with open(token_path) as f:
            self._tokens = json.load(f)

    def _persist(self, tokens):
        # write-then-rename: never leave a half-written token file
        tmp = self.token_path + '.tmp'
        with open(tmp, 'w') as f:
            json.dump(tokens, f)
        import os
        os.replace(tmp, self.token_path)
        self._tokens = tokens

    def _refresh(self):
        with self._lock:
            resp = requests.post(
                f'{self.base_url}/v1/auth/refresh',
                json={'refresh_token': self._tokens['refresh_token']},
                timeout=10,
            )
            if resp.status_code != 200:
                raise RuntimeError(f'refresh failed: {resp.status_code}')
            new = resp.json()
            # persist BEFORE returning so a crash can't lose the rotated token
            self._persist({
                'access_token': new['access_token'],
                'refresh_token': new['refresh_token'],
                'obtained_at': int(time.time()),
            })

    def get(self, path, **params):
        headers = {'Authorization': f"Bearer {self._tokens['access_token']}"}
        r = requests.get(f'{self.base_url}{path}', headers=headers, params=params, timeout=10)
        if r.status_code == 401:
            self._refresh()
            headers = {'Authorization': f"Bearer {self._tokens['access_token']}"}
            r = requests.get(f'{self.base_url}{path}', headers=headers, params=params, timeout=10)
        r.raise_for_status()
        return r.json()
Enter fullscreen mode Exit fullscreen mode

The _lock is what tames the double-refresh race on the client side. If ten threads hit a 401 at once, only one performs the network refresh; the rest wait and reuse the freshly persisted token. That, combined with the server's grace window, means normal concurrent traffic almost never trips reuse detection — only actual token forks do.

And here's the server-side access-token verification a Go edge service would run before proxying to the video data layer, showing the leeway that keeps cross-region requests from being wrongly rejected:

package auth

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/json"
    "errors"
    "strings"
    "time"
)

type Claims struct {
    Sub int64  `json:"sub"`
    Exp int64  `json:"exp"`
    Iss string `json:"iss"`
}

const leeway = 30 * time.Second

func Verify(token string, key []byte) (*Claims, error) {
    parts := strings.Split(token, ".")
    if len(parts) != 3 {
        return nil, errors.New("malformed token")
    }
    signing := parts[0] + "." + parts[1]
    mac := hmac.New(sha256.New, key)
    mac.Write([]byte(signing))
    expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
    if !hmac.Equal([]byte(expected), []byte(parts[2])) {
        return nil, errors.New("bad signature")
    }

    raw, err := base64.RawURLEncoding.DecodeString(parts[1])
    if err != nil {
        return nil, err
    }
    var c Claims
    if err := json.Unmarshal(raw, &c); err != nil {
        return nil, err
    }
    // 30s leeway absorbs cross-region clock skew
    if time.Now().Add(-leeway).Unix() > c.Exp {
        return nil, errors.New("expired")
    }
    if c.Iss != "trendvidstream" {
        return nil, errors.New("bad issuer")
    }
    return &c, nil
}
Enter fullscreen mode Exit fullscreen mode

Signature check first, claim decode second, expiry with leeway last. Reject early and cheaply.

What this bought us

Running this in production changed the security posture in concrete ways:

  • A stolen refresh token now self-destructs on first misuse instead of granting permanent access. The window of exposure dropped from 30 days to whatever gap exists before either party refreshes.
  • We get an actual alarm on theft. reuse_detected in the logs is a high-signal event we alert on, because legitimate clients (thanks to the grace window and client lock) essentially never generate it.
  • No global logout to revoke a session. Family-scoped revocation kills one compromised session without touching anyone else.
  • The signing key stays out of the FTP-deployed tree, so a deploy accident can't leak it.

The things I'd tell you to get right if you build this: verify the secret before you check for reuse, wrap redemption in a real transaction with row locking, add a grace window before you ship or you'll rage-quit your own users, and pin refresh redemption to a single writer so you never have to reason about the same refresh token being redeemed in two places at once. Rotation is only as strong as its reuse detection, and reuse detection is only usable if it doesn't fire on normal concurrent traffic. Get those two in balance and a leaked token stops being a breach and becomes a log line.

Top comments (0)