DEV Community

ahmet gedik
ahmet gedik

Posted on

Implementing JWT Refresh Token Rotation for Video API Clients

A refresh token leaked out of one of our Android TV clients last winter. Not through a server breach — through a rooted device running a sideloaded build that logged every HTTP header to a public Discord channel. The access token in that dump expired in fifteen minutes and nobody cared. The refresh token was valid for thirty days and could mint a fresh access token on demand.

For a service like ViralVidVault, where the trending-video and analytics endpoints are rate-limited per client and every upstream call costs real money in third-party API quota, a single long-lived refresh token in the wrong hands is a slow, quiet leak of both data and budget. Worse, we had no way to tell that the token was being abused, because a stolen refresh token that behaves exactly like a legitimate one is invisible.

This post is about the fix: refresh token rotation with reuse detection. Not the textbook version where you hand-wave "issue a new refresh token each time," but the parts that actually bite you — token families, the race condition when a mobile client retries mid-rotation, storage that survives a device losing power, and how we validate all of it at the edge without a database round-trip. The stack is what we run: PHP 8.4 on LiteSpeed, SQLite in WAL mode, and Cloudflare Workers in front.

Why short access tokens alone are not enough

The standard advice is "keep access tokens short-lived." We do — ours live fifteen minutes. But video clients are long-running by nature. A smart-TV app stays open for a two-hour binge; a mobile app sits in the background for days. You cannot force a login every fifteen minutes, so you need a credential the client can exchange silently for a new access token. That credential is the refresh token, and it is the real prize for an attacker precisely because it is long-lived.

Rotation changes the economics. Instead of one refresh token that works for thirty days, each refresh consumes the old token and issues a new one. The old token is immediately invalidated. This gives you two things:

  • A moving target. A refresh token captured from a log is only useful until the legitimate client refreshes again — often minutes later.
  • Theft detection. If a token that has already been rotated is ever presented again, exactly one of two parties is holding a stale copy: the attacker or the victim. Either way, something is wrong, and you can revoke the entire family.

That second property is the whole point. Rotation without reuse detection is security theater; rotation with reuse detection turns a silent compromise into a loud, actionable event.

The token family model

The mental model that made this click for me is the token family. When a client authenticates for the first time, you create a family — think of it as a session lineage. Every refresh token in that family shares a family_id. Each refresh operation:

  1. Validates the presented refresh token.
  2. Marks it as used.
  3. Issues a new refresh token in the same family, pointing back at its parent.
  4. Returns a new access token alongside it.

The family forms a linked list. Under normal operation, only the tail of the list is ever presented. If a token in the middle of the list is presented — one already marked used — you have detected reuse, and you kill the entire family by revoking every token that shares the family_id. Both the attacker and the legitimate user are logged out and forced to re-authenticate. That is the correct, conservative outcome: better a re-login than a persistent intruder.

Here is the SQLite schema we landed on. Note the family_id, the used_at timestamp, and that we store a hash of the token, never the token itself.

PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;

CREATE TABLE refresh_tokens (
    id           INTEGER PRIMARY KEY,
    family_id    TEXT    NOT NULL,
    token_hash   TEXT    NOT NULL UNIQUE,
    client_id    TEXT    NOT NULL,
    parent_id    INTEGER REFERENCES refresh_tokens(id),
    issued_at    INTEGER NOT NULL,
    expires_at   INTEGER NOT NULL,
    used_at      INTEGER,               -- NULL until rotated
    revoked_at   INTEGER,               -- NULL unless family killed
    user_agent   TEXT,
    ip_prefix    TEXT                    -- truncated for GDPR, see below
);

CREATE INDEX idx_rt_family  ON refresh_tokens(family_id);
CREATE INDEX idx_rt_hash    ON refresh_tokens(token_hash);
CREATE INDEX idx_rt_expires ON refresh_tokens(expires_at);
Enter fullscreen mode Exit fullscreen mode

WAL mode matters here more than people expect. Refresh is a read-heavy-then-write operation happening on every active client, and WAL lets our LiteSpeed workers read concurrently while a rotation writes, instead of serializing behind a global lock. On a single SQLite file serving several thousand daily-active clients, this is the difference between smooth refreshes and lock-timeout errors during traffic spikes.

Issuing and rotating tokens in PHP

The access token is a signed JWT — stateless, verified by signature, never touches the database on the hot path. The refresh token is deliberately not a JWT. It is a high-entropy opaque string. Making the refresh token opaque means it has zero meaning outside our database; there is nothing to decode, nothing to tamper with, and revocation is a single row update rather than a fight against JWT's stateless nature.

<?php
declare(strict_types=1);

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

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

    /** First login: open a brand-new family. */
    public function issueNewFamily(string $clientId, string $ua, string $ipPrefix): array
    {
        $familyId = bin2hex(random_bytes(16));
        return $this->mint($familyId, $clientId, null, $ua, $ipPrefix);
    }

    /** Create one refresh token row + matching access JWT. */
    private function mint(string $familyId, string $clientId, ?int $parentId,
                          string $ua, string $ipPrefix): array
    {
        $raw  = bin2hex(random_bytes(32));          // 256 bits of entropy
        $hash = hash('sha256', $raw);
        $now  = time();

        $stmt = $this->db->prepare(
            'INSERT INTO refresh_tokens
             (family_id, token_hash, client_id, parent_id, issued_at, expires_at, user_agent, ip_prefix)
             VALUES (:fam, :hash, :cid, :parent, :iat, :exp, :ua, :ip)'
        );
        $stmt->execute([
            ':fam'    => $familyId,
            ':hash'   => $hash,
            ':cid'    => $clientId,
            ':parent' => $parentId,
            ':iat'    => $now,
            ':exp'    => $now + self::REFRESH_TTL,
            ':ua'     => substr($ua, 0, 200),
            ':ip'     => $ipPrefix,
        ]);

        return [
            'access_token'  => $this->signAccessJwt($clientId, $familyId),
            'refresh_token' => $raw,          // returned once, never stored raw
            'expires_in'    => self::ACCESS_TTL,
        ];
    }

    private function signAccessJwt(string $clientId, string $familyId): string
    {
        $header  = ['alg' => 'HS256', 'typ' => 'JWT'];
        $now     = time();
        $payload = [
            'sub' => $clientId,
            'fam' => $familyId,          // lets the edge revoke by family
            'iat' => $now,
            'exp' => $now + self::ACCESS_TTL,
        ];
        $seg = fn(array $d) => rtrim(strtr(base64_encode(json_encode($d)), '+/', '-_'), '=');
        $signingInput = $seg($header) . '.' . $seg($payload);
        $sig = hash_hmac('sha256', $signingInput, $this->jwtSecret, true);
        return $signingInput . '.' . rtrim(strtr(base64_encode($sig), '+/', '-_'), '=');
    }
}
Enter fullscreen mode Exit fullscreen mode

A few decisions worth calling out:

  • The raw refresh token is returned exactly once. We store only its SHA-256 hash. If our database leaks, the attacker gets hashes, not usable tokens. There is no need for a slow password hash here — the token has 256 bits of entropy, so SHA-256 is both sufficient and fast enough to run on every refresh.
  • The fam claim rides inside the access JWT. This is what lets Cloudflare Workers reject a whole family at the edge without asking the origin, which I will come back to.
  • We truncate the IP before it ever hits the row. More on the GDPR reasoning shortly.

Rotation with reuse detection

This is the core of the system. The rotate method has to do four things atomically: look up the presented token, decide whether it is fresh or already used, and either issue a successor or burn the family down.

<?php
declare(strict_types=1);

final class RefreshError extends RuntimeException {}

trait RotatesTokens
{
    public function rotate(string $presented, string $ua, string $ipPrefix): array
    {
        $hash = hash('sha256', $presented);
        $this->db->beginTransaction();

        try {
            $row = $this->lockRow($hash);

            if ($row === null) {
                // Token we never issued, or already pruned. Nothing to revoke.
                throw new RefreshError('unknown_token');
            }

            $now = time();

            if ($row['revoked_at'] !== null) {
                throw new RefreshError('family_revoked');
            }

            if ($row['used_at'] !== null) {
                // REUSE DETECTED. A used token came back. Kill the family.
                $this->revokeFamily($row['family_id'], $now);
                $this->db->commit();
                throw new RefreshError('reuse_detected');
            }

            if ($row['expires_at'] < $now) {
                throw new RefreshError('expired');
            }

            // Happy path: mark used, mint successor in the same family.
            $this->markUsed((int) $row['id'], $now);
            $tokens = $this->mint(
                $row['family_id'], $row['client_id'], (int) $row['id'], $ua, $ipPrefix
            );

            $this->db->commit();
            return $tokens;
        } catch (RefreshError $e) {
            if ($this->db->inTransaction()) {
                $this->db->commit(); // revocations must persist
            }
            throw $e;
        } catch (Throwable $e) {
            if ($this->db->inTransaction()) {
                $this->db->rollBack();
            }
            throw $e;
        }
    }

    private function lockRow(string $hash): ?array
    {
        // WAL + this SELECT inside the write txn gives us a consistent read.
        $stmt = $this->db->prepare(
            'SELECT id, family_id, client_id, used_at, revoked_at, expires_at
             FROM refresh_tokens WHERE token_hash = :h'
        );
        $stmt->execute([':h' => $hash]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ?: null;
    }

    private function markUsed(int $id, int $now): void
    {
        $this->db->prepare('UPDATE refresh_tokens SET used_at = :t WHERE id = :id')
                 ->execute([':t' => $now, ':id' => $id]);
    }

    private function revokeFamily(string $familyId, int $now): void
    {
        $this->db->prepare(
            'UPDATE refresh_tokens SET revoked_at = :t
             WHERE family_id = :fam AND revoked_at IS NULL'
        )->execute([':t' => $now, ':fam' => $familyId]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The subtle part is the ordering. We check revoked_at first (family already killed), then used_at (reuse), then expiry. The reuse branch commits the revocation before throwing, because the entire value of detection is that the revocation sticks. If we rolled back on the exception, we would detect theft and then forgive it — the worst of both worlds.

The retry race every mobile client will hit

Here is the failure mode that separates a demo from production. A mobile client fires a refresh request. The server rotates successfully — old token marked used, new token written — and then the response is lost. The phone dropped to a dead cell, the app was backgrounded, whatever. The client never received the new token, so on the next foreground it retries with the old token.

With naive reuse detection, that innocent retry looks exactly like theft. You would nuke a legitimate user's session on a flaky connection, which for a video app on a train through a tunnel is constantly.

We handle this with a short grace window. When a token is rotated, we remember the successor it produced. If the same parent token is presented again within a few seconds, we do not scream theft — we replay the successor that was already issued. Only reuse outside that window, or reuse of a token whose successor has itself already been rotated, triggers family revocation. Concretely, we add a successor_id column and a check: if used_at is within, say, ten seconds and the successor has not itself been used, return the successor's data instead of revoking. This one change eliminated the overwhelming majority of false-positive logouts we saw in the first week.

The client side, done right

A correct server is undone by a sloppy client. The two rules that matter: serialize refreshes so a client never fires two concurrent refreshes (that alone creates the reuse race), and persist the new refresh token before using the access token. Here is the pattern in Go, which is what our smart-TV SDK is written in.

package vvauth

import (
    "context"
    "sync"
    "time"
)

type TokenStore interface {
    Load() (access, refresh string, exp time.Time, err error)
    Save(access, refresh string, exp time.Time) error
}

type Client struct {
    mu      sync.Mutex // serializes refresh; no concurrent rotations
    store   TokenStore
    refresh func(ctx context.Context, refreshTok string) (string, string, time.Time, error)
}

// AccessToken returns a valid access token, refreshing if needed.
func (c *Client) AccessToken(ctx context.Context) (string, error) {
    c.mu.Lock()
    defer c.mu.Unlock()

    access, refresh, exp, err := c.store.Load()
    if err != nil {
        return "", err
    }
    // 60s skew buffer so we never send an about-to-expire token.
    if time.Until(exp) > 60*time.Second {
        return access, nil
    }

    newAccess, newRefresh, newExp, err := c.refresh(ctx, refresh)
    if err != nil {
        return "", err // caller forces re-login on family_revoked
    }

    // CRITICAL: persist the rotated refresh token BEFORE returning the
    // access token. If we crash here, next boot loads the new refresh
    // token, not the consumed one.
    if err := c.store.Save(newAccess, newRefresh, newExp); err != nil {
        return "", err
    }
    return newAccess, nil
}
Enter fullscreen mode Exit fullscreen mode

The sync.Mutex is not decoration. Without it, a TV app that fires three parallel requests to /trending, /analytics, and /recommendations on startup would trigger three simultaneous refreshes with the same token — two of which would look like reuse and revoke the family before the user has watched a single clip. Serialize the refresh, and everything downstream stays calm.

Validating at the edge with Cloudflare Workers

Because the access token is a self-contained JWT with the fam claim inside it, we verify it at the Cloudflare edge and only fall back to the origin when we must. The Worker checks the signature and expiry on every request, and consults a small KV list of revoked family IDs — which we populate whenever revokeFamily runs. A revoked family is rejected in Frankfurt without the request ever crossing to origin.

export default {
  async fetch(request, env) {
    const auth = request.headers.get('Authorization') || '';
    const token = auth.startsWith('Bearer ') ? auth.slice(7) : null;
    if (!token) return new Response('missing token', { status: 401 });

    const parts = token.split('.');
    if (parts.length !== 3) return new Response('malformed', { status: 401 });

    const data = `${parts[0]}.${parts[1]}`;
    const key = await crypto.subtle.importKey(
      'raw', new TextEncoder().encode(env.JWT_SECRET),
      { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']
    );
    const sig = Uint8Array.from(
      atob(parts[2].replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)
    );
    const ok = await crypto.subtle.verify('HMAC', key, sig, new TextEncoder().encode(data));
    if (!ok) return new Response('bad signature', { status: 401 });

    const claims = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
    if (claims.exp < Math.floor(Date.now() / 1000)) {
      return new Response('expired', { status: 401 });
    }

    // Revoked families are pushed to KV by revokeFamily() on the origin.
    if (await env.REVOKED.get(claims.fam)) {
      return new Response('revoked', { status: 401 });
    }
    return fetch(request); // pass through to origin
  },
};
Enter fullscreen mode Exit fullscreen mode

The KV entries get a TTL equal to the access-token lifetime plus a small buffer. There is no point keeping a family in the revocation list longer than the longest-lived access token that could carry that fam claim — once those fifteen-minute tokens are all expired, the JWT check alone rejects them. This keeps the edge lookup table tiny and cheap.

GDPR: the part European services cannot skip

Refresh-token tables are session-tracking tables, and under GDPR that makes them personal data by default. A few things we do to stay clean:

  • Truncate IP addresses. We store ip_prefix — the first three octets of an IPv4 address or the /48 of an IPv6 — never the full address. It is enough to spot an obvious geographic anomaly during reuse investigation, but it is not, on its own, an identifier we retain.
  • Hash the token, always. Already covered, but it doubles as data minimization: a hash is not a reversible credential.
  • Enforce retention. A daily job deletes rows where expires_at is more than a few days in the past and revoked_at is set. Dead sessions do not linger.
  • Support erasure. When a user exercises their right to be forgotten, deleting by client_id wipes every family and every token in one query, because the schema keys on it.

Retention is one line you should actually run on a schedule:

DELETE FROM refresh_tokens
WHERE expires_at < strftime('%s','now') - 604800; -- 7-day tail, then gone
Enter fullscreen mode Exit fullscreen mode

None of this is optional theater for a European product. Storing full IPs and raw tokens against sessions, with no retention limit, is exactly the kind of thing that turns a minor incident into a reportable one.

Conclusion

Refresh-token rotation is not hard to describe and surprisingly easy to get wrong in the two places that matter: the reuse-detection branch that must commit before it throws, and the client that must serialize refreshes and persist the new token before spending the access token. Get those right and you convert a stolen refresh token from a silent, month-long liability into a detected event that logs everyone out and forces a clean re-auth.

The combination that has held up for us — opaque hashed refresh tokens in WAL-mode SQLite, a stateless access JWT carrying a family claim, a short grace window for the mobile-retry race, and edge revocation at Cloudflare — costs almost nothing per request and gives you a real answer to the question every video API operator should be able to answer: if a client's credentials leak, how quickly do we find out, and how fast can we shut it down? Start with the token family model, add reuse detection, then harden the client. Everything else is refinement.

Top comments (0)