A smart TV app on someone's living-room wall stays signed in for weeks. A mobile client backgrounds for days. A web player runs in a tab that never closes. Every one of those sessions is a long-lived surface, and if you hand each of them a single JWT with a comfortable expiry, you have quietly built a replay machine: one token pulled from a rooted device, a proxied network, or a leaked log gets replayed against /v1/feed and /v1/watch until it expires — and if you stretched that expiry to avoid re-auth friction, the attacker's window is measured in days.
On DailyWatch we run a free video-discovery API hit by browsers, mobile apps, and TV clients, and the pattern that lets us keep replay windows down to minutes without logging people out every hour is short-lived access tokens paired with rotating refresh tokens. This post is the concrete implementation — schema, PHP 8.4 endpoint code, reuse detection, and the client-side refresh loop — not the hand-wavy "use refresh tokens" advice you have already read ten times.
The threat model that actually matters for video clients
Rotation only earns its complexity if you are clear about what it defends against. For a video API the realistic threats are:
- Access token replay. A stolen access token is usable until it expires. You minimize damage by making that expiry tiny (5–15 minutes) and accepting that clients refresh often.
- Refresh token theft. The refresh token is the long-lived credential. If it never changes, stealing it once is game over. Rotation means every refresh invalidates the old token and issues a new one, so a stolen token is only useful until the legitimate client refreshes next.
- Undetected compromise. The real prize: if both the attacker and the legitimate client hold the same rotated refresh token, the next rotation from either one lets you detect that two parties share a lineage — and you kill the entire family.
The access token stays a plain stateless JWT. The refresh token is where the state lives. People conflate the two and try to make refresh tokens stateless JWTs as well, which defeats the entire point: you cannot revoke something you do not track.
Access tokens stay stateless, refresh tokens get a database
Here is the division of labor:
- The access token is a signed JWT. Your edge (LiteSpeed workers, or a Cloudflare Worker in front of origin) verifies the signature and
expwith no database round trip. This is what keeps a high-traffic video feed cheap to serve. - The refresh token is an opaque, high-entropy string. You store only a hash of it in SQLite, alongside the family it belongs to and whether it has been used. Refreshing is the only operation that touches the database, and it happens once every few minutes per client, not on every feed scroll.
We use SQLite because the whole DailyWatch stack is SQLite + FTS5 behind LiteSpeed, and refresh volume is low enough that a single well-indexed table handles it comfortably. The same schema works on Postgres or MySQL verbatim.
Schema and rotation logic in SQLite
The core table stores refresh tokens as a chain. Each token knows its family_id (constant across a login session) and whether it has been used. When a token is rotated, we mark it used and insert its successor with the same family.
CREATE TABLE refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the opaque token
family_id TEXT NOT NULL, -- constant for one login lineage
user_id INTEGER NOT NULL,
client_kind TEXT NOT NULL, -- 'web' | 'mobile' | 'tv'
used INTEGER NOT NULL DEFAULT 0,
revoked INTEGER NOT NULL DEFAULT 0,
issued_at INTEGER NOT NULL, -- unix seconds
expires_at INTEGER NOT NULL,
prev_id INTEGER, -- link to the token it replaced
FOREIGN KEY (prev_id) REFERENCES refresh_tokens(id)
);
CREATE INDEX idx_rt_family ON refresh_tokens(family_id);
CREATE INDEX idx_rt_user ON refresh_tokens(user_id);
CREATE INDEX idx_rt_expires ON refresh_tokens(expires_at);
A few deliberate choices:
- We store
token_hash, never the raw token. A database leak should not hand out working credentials. SHA-256 is fine here because the token itself is already 256 bits of randomness — you are not defending against a dictionary attack, so you do not need a slow KDF like Argon2. -
family_idis the unit of revocation. Kill the family, kill every device sharing that lineage. -
prev_idgives you an audit chain, useful when you need to reason about when a compromise happened.
The rotation endpoint in PHP 8.4
Now the heart of it. The refresh endpoint accepts a refresh token, validates it, and — atomically — marks it used, issues a new refresh token in the same family, and mints a fresh access JWT. PHP 8.4 lets us lean on constructor property promotion, typed constants, and readonly classes for a tidy service.
<?php
declare(strict_types=1);
final readonly class TokenService
{
private const int ACCESS_TTL = 600; // 10 minutes
private const int REFRESH_TTL = 60 * 60 * 24 * 30; // 30 days
public function __construct(
private PDO $db,
private string $jwtSecret,
) {}
/** Rotate a refresh token. Throws on reuse or invalidity. */
public function rotate(string $rawToken): array
{
$hash = hash('sha256', $rawToken);
$now = time();
$this->db->beginTransaction();
try {
$row = $this->db->prepare(
'SELECT * FROM refresh_tokens WHERE token_hash = ? LIMIT 1'
);
$row->execute([$hash]);
$token = $row->fetch(PDO::FETCH_ASSOC);
if ($token === false) {
throw new RefreshError('unknown token');
}
if ((int) $token['revoked'] === 1) {
throw new RefreshError('family revoked');
}
// Reuse of an already-rotated token: compromise signal.
if ((int) $token['used'] === 1) {
$this->revokeFamily($token['family_id']);
$this->db->commit();
throw new RefreshReuse($token['family_id']);
}
if ((int) $token['expires_at'] < $now) {
throw new RefreshError('expired');
}
// Mark current token used.
$this->db->prepare(
'UPDATE refresh_tokens SET used = 1 WHERE id = ?'
)->execute([$token['id']]);
// Issue the successor in the same family.
$newRaw = bin2hex(random_bytes(32));
$newHash = hash('sha256', $newRaw);
$this->db->prepare(
'INSERT INTO refresh_tokens
(token_hash, family_id, user_id, client_kind,
issued_at, expires_at, prev_id)
VALUES (?, ?, ?, ?, ?, ?, ?)'
)->execute([
$newHash,
$token['family_id'],
$token['user_id'],
$token['client_kind'],
$now,
$now + self::REFRESH_TTL,
$token['id'],
]);
$this->db->commit();
} catch (Throwable $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
return [
'access_token' => $this->mintAccessJwt(
(int) $token['user_id'], $token['client_kind']
),
'refresh_token' => $newRaw,
'expires_in' => self::ACCESS_TTL,
];
}
private function revokeFamily(string $familyId): void
{
$this->db->prepare(
'UPDATE refresh_tokens SET revoked = 1 WHERE family_id = ?'
)->execute([$familyId]);
}
private function mintAccessJwt(int $userId, string $clientKind): string
{
$now = time();
$header = ['alg' => 'HS256', 'typ' => 'JWT'];
$payload = [
'sub' => $userId,
'ck' => $clientKind,
'iat' => $now,
'exp' => $now + self::ACCESS_TTL,
'jti' => bin2hex(random_bytes(8)),
];
$seg = static fn(array $p): string => rtrim(strtr(
base64_encode(json_encode($p, JSON_THROW_ON_ERROR)), '+/', '-_'
), '=');
$signing = $seg($header) . '.' . $seg($payload);
$sig = hash_hmac('sha256', $signing, $this->jwtSecret, true);
return $signing . '.' . rtrim(strtr(
base64_encode($sig), '+/', '-_'
), '=');
}
}
final class RefreshError extends RuntimeException {}
final class RefreshReuse extends RuntimeException
{
public function __construct(public readonly string $familyId)
{
parent::__construct('refresh token reuse detected');
}
}
The transaction boundary is not decorative. Marking the old token used and inserting the successor must be one atomic unit, or a crash between the two steps leaves a client with a used token and no successor — a permanent logout. SQLite gives you serializable transactions for free, so the beginTransaction / commit pair around both writes is exactly what you want. If you run multiple LiteSpeed workers, enable WAL mode (PRAGMA journal_mode=WAL) so concurrent readers do not block the writer.
Detecting refresh token reuse — the whole point
The used === 1 branch above is the feature that makes rotation worth building. Walk through the compromise scenario:
- A client refreshes and receives token
B, replacingA.Ais now markedused. - An attacker who copied
Aearlier tries to refresh with it. - Your endpoint sees
A.used === 1— a token that was already rotated is being presented again. Legitimate clients never do this; they always hold the newest token. - You revoke the entire family, forcing both the attacker and the legitimate client to re-authenticate.
That last step feels harsh — you are logging out a real user — but it is correct. Once two parties share a token lineage you cannot tell which one is the thief, so you burn the family and let the real user log in fresh. The alternative, letting it ride, means an attacker who stays one refresh ahead of the client keeps access indefinitely.
There is a subtle race worth calling out: a flaky mobile network can make a legitimate client retry a refresh it already completed (it never got the response), presenting a used token and tripping reuse detection. Two mitigations that work in practice:
- Idempotency grace window. If a used token is re-presented within a few seconds of its rotation and from the same client fingerprint, return the already-issued successor instead of revoking. Store the successor's raw token encrypted for that short window, or simply relax to "return 401 and let the client fall back to its stored newer token" if you kept it.
- Client-side single-flight. Serialize refreshes on the client so you never fire two concurrent refreshes for the same token. This is cheaper and eliminates most of the races before they reach the server.
The client-side refresh loop
Rotation is a contract between server and client, and the client half is where most bugs live. A video client makes many concurrent requests — thumbnails, feed pages, watch metadata — and if three of them get a 401 at once and each fires its own refresh, two of them will present the now-used token and trip your reuse detector. The fix is single-flight: at most one refresh in progress, everyone else waits for it.
import asyncio
import time
import httpx
class VideoApiClient:
def __init__(self, base_url: str, refresh_token: str):
self._base = base_url
self._refresh_token = refresh_token
self._access_token: str | None = None
self._access_exp = 0.0
self._refresh_lock = asyncio.Lock()
self._http = httpx.AsyncClient(base_url=base_url)
async def _ensure_access(self) -> str:
# Fast path: token still valid with a safety margin.
if self._access_token and time.time() < self._access_exp - 30:
return self._access_token
# Single-flight: only one coroutine refreshes; others await the lock
# and then see the freshly minted token via the fast path.
async with self._refresh_lock:
if self._access_token and time.time() < self._access_exp - 30:
return self._access_token
resp = await self._http.post(
"/v1/auth/refresh",
json={"refresh_token": self._refresh_token},
)
if resp.status_code == 401:
raise PermissionError("refresh rejected, re-login required")
resp.raise_for_status()
data = resp.json()
# Persist the ROTATED refresh token immediately.
self._refresh_token = data["refresh_token"]
self._access_token = data["access_token"]
self._access_exp = time.time() + data["expires_in"]
return self._access_token
async def get_feed(self, region: str) -> dict:
token = await self._ensure_access()
resp = await self._http.get(
"/v1/feed",
params={"region": region},
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
return resp.json()
The two rules a correct client must follow:
- Persist the new refresh token the instant you receive it, before making any other request. If the app is killed between refresh and persistence, you lose the successor and force a re-login. On mobile, write it to the keychain/keystore synchronously in that block.
- Never run two refreshes concurrently. The lock plus the double-check inside it is the whole trick — the second coroutine acquires the lock after the first finished, sees a valid token, and skips the network call entirely.
Verifying access tokens at the edge
Because the access token is a stateless JWT, verification is a signature check with no database hit — which is what lets a video feed scale. Whether you verify in a LiteSpeed PHP worker or a Cloudflare Worker in front of origin, the logic is identical. Here it is in Go for a middleware layer, since a lot of edge verifiers end up in Go:
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
)
type Claims struct {
Sub int64 `json:"sub"`
Ck string `json:"ck"`
Exp int64 `json:"exp"`
}
var ErrInvalidToken = errors.New("invalid access token")
func VerifyAccess(token, secret string) (*Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, ErrInvalidToken
}
signing := parts[0] + "." + parts[1]
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signing))
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
// Constant-time compare defeats timing side channels.
if !hmac.Equal([]byte(expected), []byte(parts[2])) {
return nil, ErrInvalidToken
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, ErrInvalidToken
}
var c Claims
if err := json.Unmarshal(raw, &c); err != nil {
return nil, ErrInvalidToken
}
if time.Now().Unix() >= c.Exp {
return nil, ErrInvalidToken
}
return &c, nil
}
Two details that trip people up:
- Use a constant-time comparison (
hmac.Equal,hash_equalsin PHP) for the signature. A naive==leaks how many bytes matched via timing, which is enough to forge a signature byte by byte. -
Verify
expyourself. Some JWT libraries skip expiry validation unless you opt in. The whole security model rests on short access-token lifetimes; an unverifiedexpthrows it away.
Handling clients that go offline for weeks
TV and mobile clients disappear for long stretches. A 30-day refresh TTL means a device that has been off for five weeks comes back with a dead refresh token and must re-login — acceptable for most apps. If you need longer, do not just crank the TTL; instead, extend it on each successful rotation (a sliding window) so active devices stay signed in indefinitely while truly abandoned tokens still age out. The expires_at you compute for each successor in the PHP rotate method already does this — every refresh resets the 30-day clock.
Also run a cleanup job. Expired and revoked tokens accumulate, and on SQLite you want to prune them so the indexes stay lean:
- Delete rows where
expires_at < now() - graceon a nightly cron. - Keep revoked-family rows for a short forensic window before deleting, so you can answer "when did this family get killed and why."
- Run
PRAGMA optimizeafter the delete so the query planner keeps good statistics.
Rollout notes for LiteSpeed and Cloudflare
A few operational things that bit us:
-
Do not cache the refresh endpoint. Behind Cloudflare, mark
/v1/auth/refreshasno-storeand bypass cache explicitly. A cached refresh response is a catastrophe — you would serve one client's rotated token to another. -
Watch Set-Cookie if you store refresh tokens in cookies. If the same cookie value is re-sent, LiteSpeed plus Cloudflare can cache it; only emit
Set-Cookiewhen the value actually changes. For pure API clients, prefer returning the token in the JSON body and storing it in native secure storage instead of a cookie. - Enable WAL on SQLite so concurrent LiteSpeed workers do not serialize on the refresh write.
-
Clock skew. Give access-token
expverification a small leeway (a few seconds) so a client whose clock is slightly ahead does not reject a just-minted token.
Conclusion
Refresh-token rotation is not about making tokens fancier — it is about shrinking the blast radius of a leak to a single refresh interval and, crucially, turning token theft into a detectable event via reuse detection. The moving parts are small: a stateless access JWT verified at the edge, an opaque refresh token tracked as a family in SQLite, atomic rotation on every refresh, and a single-flight client that persists the new token before anything else. Get the transaction boundary and the reuse branch right and the rest is plumbing. Everything above is running in production behind the same PHP 8.4 + SQLite + LiteSpeed + Cloudflare stack we use for the video feed itself, and it has kept our replay windows down to minutes without adding a database round trip to the hot path.
Top comments (0)