DEV Community

ahmet gedik
ahmet gedik

Posted on

Implementing JWT Refresh Token Rotation for Video API Clients

Last month a partner app shipped one of our access tokens into a debug log that got scraped off a public CI artifact. On most APIs that is a slow-motion disaster: the token stays valid until it expires, and if you issued long-lived tokens to keep client developers from re-authenticating every few minutes, "until it expires" can mean weeks. At TrendVidStream we serve a streaming-discovery API to clients in 8 regions, and a leaked token means someone can pull our ranked feeds, exhaust per-region rate limits, and poison the recommendation telemetry we build rankings on. Short-lived access tokens combined with refresh-token rotation is the fix — one of the few security controls that actually shrinks the attack surface without making the client developer's life miserable. This post is the exact scheme we run in production: the SQLite schema, the PHP 8.4 server, concurrency-safe Python and Go clients, and the traps nobody warns you about.

The problem a token that never dies

Every token scheme is a negotiation between two pressures that pull in opposite directions:

  • Short expiry is safer. A stolen token is only useful for the seconds left on its clock. But if access tokens expire in 10 minutes and that is all you issue, clients re-authenticate constantly and your login endpoint becomes the hottest path in the system.
  • Long expiry is convenient. One token that lasts two weeks means the client authenticates once and forgets about it. But now a leaked token is a two-week skeleton key, and you have no way to tell a legitimate long-lived session from a stolen one.

Rotation resolves the tension by splitting the job across two tokens with completely different lifetimes and storage models. The access token is short-lived and disposable; the refresh token is long-lived, single-use, and traceable. You get the convenience of long sessions and the blast radius of short ones.

What rotation actually means

People say "rotation" and mean three subtly different things, so here is the precise contract we implement:

  • Access token: a stateless HS256 JWT with a 10-minute expiry. We never store it server-side. Any edge node validates it with the shared secret and a kid header, so a request to our ap-south node never has to phone home to us-east.
  • Refresh token: a long opaque random string (not a JWT), stored only as a SHA-256 hash, single-use, valid for 14 days.
  • Rotation: every time a refresh token is redeemed, it is marked used and a brand-new refresh token is minted in return. The old one is dead the instant it is used.
  • Reuse detection: refresh tokens carry a family id shared across the whole lineage. If a token that was already used shows up again, that is either a bug or an attacker replaying a stolen token — so we revoke the entire family and force a real re-authentication.

That last point is what makes rotation worth the effort. Without reuse detection you have rotation for cosmetic reasons. With it, a leaked refresh token self-destructs the moment either the attacker or the real client uses it a second time, and both parties get locked out — which is exactly what you want, because it turns a silent compromise into a loud, detectable event.

The SQLite schema

We run SQLite with FTS5 for the discovery data and a separate auth.db for tokens. One table does the whole job. The family column is the lineage id; used_at being NULL is the definition of "still live".

CREATE TABLE refresh_tokens (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    token_hash TEXT    NOT NULL UNIQUE,   -- sha256 of the opaque token
    client_id  INTEGER NOT NULL,
    region     TEXT    NOT NULL,          -- us-east, eu-west, ap-south, ...
    family     TEXT    NOT NULL,          -- lineage id, shared across rotations
    issued_at  INTEGER NOT NULL,
    expires_at INTEGER NOT NULL,
    used_at    INTEGER,                   -- set on rotation; NULL = still live
    revoked    INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_rt_family  ON refresh_tokens(family);
CREATE INDEX idx_rt_expires ON refresh_tokens(expires_at);
Enter fullscreen mode Exit fullscreen mode

We store the SHA-256 of the token, never the token itself. A read-only leak of auth.db — say, a stray copy pulled over FTP — hands an attacker a table of useless hashes rather than a table of live credentials. The UNIQUE constraint on token_hash also gives us a cheap collision guarantee for free.

Issuing and rotating on the server (PHP 8.4)

Here is the core of the server. A tiny self-contained Jwt helper signs the access token, and AuthService handles issuance, rotation, and reuse detection. It is runnable as-is against a PDO SQLite handle — no external dependencies.

<?php
declare(strict_types=1);

final class Jwt
{
    public function __construct(
        private string $secret,
        private string $kid,
    ) {}

    public function sign(array $claims): string
    {
        $b64 = static fn(array $x): string => rtrim(strtr(
            base64_encode(json_encode($x, JSON_UNESCAPED_SLASHES)),
            '+/', '-_'), '=');
        $head = $b64(['alg' => 'HS256', 'typ' => 'JWT', 'kid' => $this->kid]);
        $body = $b64($claims);
        $sig  = hash_hmac('sha256', "$head.$body", $this->secret, true);
        $tail = rtrim(strtr(base64_encode($sig), '+/', '-_'), '=');
        return "$head.$body.$tail";
    }
}

final class AuthException extends \RuntimeException {}

final class AuthService
{
    public function __construct(
        private PDO $db,
        private Jwt $jwt,
        private int $accessTtl  = 600,       // 10 minutes
        private int $refreshTtl = 1209600,   // 14 days
    ) {}

    /** Issue a fresh pair and start a new token family. */
    public function issue(int $clientId, string $region): array
    {
        $family = bin2hex(random_bytes(16));
        return $this->mint($clientId, $region, $family);
    }

    /** Exchange a refresh token for a new pair, rotating the old one out. */
    public function refresh(string $presented): array
    {
        $hash = hash('sha256', $presented);
        $stmt = $this->db->prepare(
            'SELECT id, client_id, region, family, used_at, revoked, expires_at
               FROM refresh_tokens WHERE token_hash = :h'
        );
        $stmt->execute([':h' => $hash]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        if ($row === false) {
            throw new AuthException('unknown_refresh_token');
        }
        if ((int) $row['revoked'] === 1) {
            throw new AuthException('family_revoked');
        }
        if ((int) $row['expires_at'] < time()) {
            throw new AuthException('expired_refresh_token');
        }
        // Reuse detection: an already-rotated token came back. Burn the family.
        if ($row['used_at'] !== null) {
            $this->revokeFamily($row['family']);
            throw new AuthException('refresh_reuse_detected');
        }

        $this->db->beginTransaction();
        try {
            $mark = $this->db->prepare(
                'UPDATE refresh_tokens SET used_at = :now
                   WHERE id = :id AND used_at IS NULL'
            );
            $mark->execute([':now' => time(), ':id' => $row['id']]);
            if ($mark->rowCount() === 0) {
                // Lost the race: a concurrent refresh already consumed this row.
                $this->db->rollBack();
                $this->revokeFamily($row['family']);
                throw new AuthException('refresh_reuse_detected');
            }
            $pair = $this->mint(
                (int) $row['client_id'], $row['region'], $row['family']
            );
            $this->db->commit();
            return $pair;
        } catch (\Throwable $e) {
            if ($this->db->inTransaction()) {
                $this->db->rollBack();
            }
            throw $e;
        }
    }

    private function mint(int $clientId, string $region, string $family): array
    {
        $refresh = bin2hex(random_bytes(32));
        $now = time();
        $ins = $this->db->prepare(
            'INSERT INTO refresh_tokens
               (token_hash, client_id, region, family, issued_at, expires_at)
             VALUES (:h, :c, :r, :f, :i, :e)'
        );
        $ins->execute([
            ':h' => hash('sha256', $refresh),
            ':c' => $clientId,
            ':r' => $region,
            ':f' => $family,
            ':i' => $now,
            ':e' => $now + $this->refreshTtl,
        ]);

        $access = $this->jwt->sign([
            'sub'    => $clientId,
            'region' => $region,
            'scope'  => 'discovery.read',
            'iat'    => $now,
            'exp'    => $now + $this->accessTtl,
        ]);

        return [
            'access_token'  => $access,
            'refresh_token' => $refresh,
            'expires_in'    => $this->accessTtl,
        ];
    }

    private function revokeFamily(string $family): void
    {
        $this->db->prepare(
            'UPDATE refresh_tokens
                SET revoked = 1, used_at = COALESCE(used_at, :now)
              WHERE family = :f'
        )->execute([':now' => time(), ':f' => $family]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The important detail is the conditional UPDATE ... WHERE id = :id AND used_at IS NULL. Two requests can read the same live row before either writes; the WHERE used_at IS NULL clause means only one UPDATE can affect a row, and rowCount() tells the loser it lost. The loser does not silently retry — it treats the collision as reuse and revokes the family, because a legitimate client never fires two refreshes for the same token on purpose. (We soften that with a small grace window in production; see the gotchas.)

The client side surviving rotation (Python)

Rotation moves complexity onto the client: the refresh token changes on every use, so a client that forgets to persist the new one locks itself out permanently on the next call. This is by far the most common integration bug we see. Here is a TokenStore that does it correctly, with a skew buffer and a single 401-triggered retry.

import threading
import time
import requests


class TokenStore:
    def __init__(self, base_url: str, refresh_token: str):
        self._base = base_url.rstrip("/")
        self._refresh = refresh_token
        self._access = None
        self._exp = 0.0
        self._lock = threading.Lock()

    def _rotate(self) -> None:
        r = requests.post(
            f"{self._base}/auth/refresh",
            json={"refresh_token": self._refresh},
            timeout=10,
        )
        if r.status_code == 401:
            raise RuntimeError("refresh rejected - full re-authentication needed")
        r.raise_for_status()
        data = r.json()
        self._access = data["access_token"]
        self._refresh = data["refresh_token"]          # keep the rotated token!
        self._persist(self._refresh)                   # and durably store it
        self._exp = time.time() + data["expires_in"] - 30  # 30s skew buffer

    def _persist(self, refresh: str) -> None:
        # Write to disk/keychain BEFORE using the new access token, so a crash
        # never leaves you holding a refresh token the server already retired.
        with open(".refresh_token", "w") as fh:
            fh.write(refresh)

    def token(self) -> str:
        with self._lock:
            if not self._access or time.time() >= self._exp:
                self._rotate()
            return self._access

    def get(self, path: str):
        headers = {"Authorization": f"Bearer {self.token()}"}
        resp = requests.get(f"{self._base}{path}", headers=headers, timeout=10)
        if resp.status_code == 401:
            with self._lock:
                self._exp = 0.0            # force a rotate on the next token()
            headers = {"Authorization": f"Bearer {self.token()}"}
            resp = requests.get(f"{self._base}{path}", headers=headers, timeout=10)
        resp.raise_for_status()
        return resp.json()
Enter fullscreen mode Exit fullscreen mode

The ordering in _rotate is deliberate: persist the new refresh token to durable storage before you rely on the new access token. If the process crashes between receiving the response and writing it down, you must recover with the token the server considers current — otherwise you replay the old one and trip your own reuse detection.

Making refresh concurrency-safe (Go)

Go clients tend to be the ones that break rotation, because they fan out dozens of goroutines that all hit a 401 at the same instant and all try to refresh. Without coordination that is N concurrent refreshes of the same token, and every one after the first is a reuse-detection hit that revokes the family. A single mutex around the rotate-or-return decision collapses the stampede into exactly one refresh.

package videoclient

import (
    "encoding/json"
    "errors"
    "net/http"
    "strings"
    "sync"
    "time"
)

type Tokens struct {
    mu      sync.Mutex
    baseURL string
    access  string
    refresh string
    expires time.Time
    http    *http.Client
}

func (t *Tokens) rotate() error {
    body, _ := json.Marshal(map[string]string{"refresh_token": t.refresh})
    resp, err := t.http.Post(
        t.baseURL+"/auth/refresh", "application/json", strings.NewReader(string(body)))
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode == http.StatusUnauthorized {
        return errors.New("refresh rejected: full re-authentication needed")
    }
    var out struct {
        Access  string `json:"access_token"`
        Refresh string `json:"refresh_token"`
        In      int    `json:"expires_in"`
    }
    if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
        return err
    }
    t.access = out.Access
    t.refresh = out.Refresh // rotation: adopt the new refresh token
    t.expires = time.Now().Add(time.Duration(out.In-30) * time.Second)
    return nil
}

// Token returns a valid access token, rotating at most once even when
// dozens of goroutines call it simultaneously.
func (t *Tokens) Token() (string, error) {
    t.mu.Lock()
    defer t.mu.Unlock()
    if t.access == "" || time.Now().After(t.expires) {
        if err := t.rotate(); err != nil {
            return "", err
        }
    }
    return t.access, nil
}
Enter fullscreen mode Exit fullscreen mode

Because the freshness check and the rotate live inside the same lock, the first goroutine through does the network round-trip while the rest block; when they acquire the lock the token is already fresh and they return immediately. One refresh, one rotation, zero false reuse hits.

Cleaning up across regions with cron

Each edge node runs its own auth.db, and each has a cron job that prunes dead rows and surfaces reuse-detection events for alerting. We keep used rows around for 24 hours so that a replay still has something to trip on — delete them too eagerly and a stolen token that comes back later looks like an unknown token instead of a reused one, and you lose the family-revocation signal.

#!/usr/bin/env php
<?php
declare(strict_types=1);

// Runs from the per-region cron on each edge node.
$db = new PDO('sqlite:' . __DIR__ . '/data/auth.db');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('PRAGMA busy_timeout = 5000');   // wait, don't fail, under write locks

$now = time();

// 1. Purge expired tokens and used tokens older than 24h.
$purge = $db->prepare(
    'DELETE FROM refresh_tokens
      WHERE expires_at < :now
         OR (used_at IS NOT NULL AND used_at < :cutoff)'
);
$purge->execute([':now' => $now, ':cutoff' => $now - 86400]);

// 2. Report families force-revoked in the last hour - these are the
//    reuse-detection hits worth paging on.
$hits = $db->query(
    'SELECT region, COUNT(DISTINCT family) AS families
       FROM refresh_tokens
      WHERE revoked = 1 AND used_at > ' . ($now - 3600) . '
      GROUP BY region'
)->fetchAll(PDO::FETCH_ASSOC);

fwrite(STDERR, sprintf(
    "auth-cleanup purged=%d reuse_families=%s\n",
    $purge->rowCount(), json_encode($hits)
));
Enter fullscreen mode Exit fullscreen mode

Gotchas we hit in production

None of these are in the RFCs. All of them cost us a debugging session:

  • Never FTP-sync auth.db. Our deploys are FTP-based, and the first version of the deploy script happily overwrote each node's live token database with a stale local copy, silently invalidating every active session on push. Exclude data/ from the sync and let each region own its own token store. Code deploys; state does not.
  • Persist the rotated refresh token before using the new access token. This is the number-one client bug. If you use the new access token and crash before writing down the new refresh token, your next refresh replays a retired token and revokes your own family.
  • Add a small reuse grace window. A flaky network can make a client resend the exact same refresh request after a timeout, even though the server already processed the first one. Punishing that with immediate family revocation generates false lockouts. We allow the same rotated response to be returned once within a 10-second idempotency window (keyed on the presented hash) before we treat a repeat as a genuine attack.
  • Budget for clock skew. Access tokens are validated against exp on eight independent nodes whose clocks drift. Give validation a few seconds of leeway and have clients refresh 30 seconds early, as the samples above do, so a slightly-fast client clock never presents an already-expired token.
  • Mind SQLite write contention. Under a refresh burst, concurrent writers will collide. PRAGMA busy_timeout turns an instant SQLITE_BUSY failure into a short wait, and keeping the auth store per-region rather than shared keeps write volume low enough that a single-writer database is never the bottleneck.
  • Rotate signing keys with kid. The access token header carries a kid so you can introduce a new HMAC secret, sign with it, and still validate tokens minted under the old key until they age out — no flag-day, no mass invalidation.

Conclusion

Refresh-token rotation is not complicated, but it is unforgiving: the security payoff comes entirely from single-use tokens plus reuse detection, and both of those turn ordinary client sloppiness — a dropped persist, a goroutine stampede, a network retry — into a lockout unless you handle it deliberately. Get the server contract right first (mark-then-mint inside one transaction, revoke the whole family on replay), then make every client persist the rotated token before it trusts the new access token, and collapse concurrent refreshes behind a single lock. Do that and a leaked token stops being a two-week skeleton key and becomes a self-destructing, loudly-alerting event — which, for an API that ranks video across eight regions, is exactly the trade you want. Steal the schema and the client patterns above; they are the same ones running in production for us today.

Top comments (0)