Our video API had a bug that only showed up in the German traffic logs. A user opens the ViralVidVault Android app, the access token expires mid-scroll, the app fires a refresh, and simultaneously a background prefetch worker fires its own refresh with the same token. Two requests, one token. Our old implementation invalidated the refresh token on first use and issued a new one — so whichever request arrived second got a 401, the client cleared its session, and the user was logged out while watching a video. We saw this roughly 40 times a day, almost always on mobile networks where retries and connection races are common.
The naive fix is to stop rotating refresh tokens. That is the wrong fix: a long-lived non-rotating refresh token stolen from local storage grants an attacker indefinite access with no way to detect it. Rotation is what makes theft detectable. This article covers how we implemented rotation with reuse detection on ViralVidVault, running PHP 8.4 against SQLite in WAL mode behind LiteSpeed, including the grace-window mechanism that killed the race condition and the GDPR constraints that shaped the token table schema.
Why Rotation Changes the Threat Model
A refresh token is a bearer credential with a long lifetime. If it never rotates, these two worlds are indistinguishable from the server's perspective:
- A legitimate client refreshing every 15 minutes for 30 days.
- An attacker who exfiltrated the token on day 1 and has been refreshing every 15 minutes since.
Rotation introduces a serial number. Each refresh consumes token N and issues token N+1, both belonging to the same family (a chain rooted at the original login). The rule is: any attempt to use a consumed token is evidence of duplication. Either the attacker used the stolen token first (and the legitimate client now presents a stale one), or the legitimate client used it first (and the attacker now presents a stale one). Either way, the server sees a token that was already spent, and the correct response is to revoke the entire family and force re-authentication.
This is the reuse-detection pattern described in OAuth 2.0 Security Best Current Practice (RFC 9700). It converts a silent, permanent compromise into a loud, bounded one: the attacker gets at most one refresh cycle before the family dies.
The cost is exactly the bug we hit. Legitimate clients also produce duplicate refreshes — from network retries, from concurrent tabs, from a mobile OS killing and restoring a process mid-request. If every duplicate triggers family revocation, you have built a logout cannon.
Schema Design Under GDPR
Before the code, the storage. Refresh tokens tie directly to an identifiable user, so under GDPR they are personal data and everything attached to them inherits that classification. Two decisions followed from that:
Store hashes, not tokens. The database holds SHA-256 of the token, never the token itself. A database dump — from a backup leak, from an SQL injection — yields nothing usable. We use SHA-256 rather than bcrypt/argon2 here because refresh tokens are 256 bits of CSPRNG output, not user-chosen passwords: there is no dictionary to attack, so the slow-hash cost buys nothing and would add latency to every refresh.
Do not store IP addresses in the token table. Our first draft logged ip_address per token for anomaly detection. Under Article 6 that needs a legitimate-interest assessment, and it means refresh tokens become part of any Article 15 subject access request. We replaced it with a coarse client_region (two-letter country, derived at issue time from the Cloudflare CF-IPCountry header) and a user_agent_hash. Region granularity is enough to notice a Berlin session suddenly refreshing from another continent, without retaining an identifier that pinpoints a household.
CREATE TABLE refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
family_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
issued_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
consumed_at INTEGER,
replaced_by TEXT,
client_region TEXT,
user_agent_hash TEXT,
revoked_at INTEGER,
revoke_reason TEXT
);
CREATE INDEX idx_rt_family ON refresh_tokens(family_id);
CREATE INDEX idx_rt_user ON refresh_tokens(user_id, expires_at);
CREATE INDEX idx_rt_cleanup ON refresh_tokens(expires_at);
CREATE TABLE token_families (
family_id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
revoked_at INTEGER,
revoke_reason TEXT
);
The token_families table exists so revocation is a single-row write. Revoking a family by updating every member row is O(chain length) and, worse, races with an in-flight refresh appending a new row to that same chain. One row, one write, checked on every refresh.
On SQLite specifically: WAL mode is not optional for this workload. Refresh requests are write transactions, and in rollback-journal mode a single writer blocks all readers — meaning every video-metadata read on the site would stall behind token writes. With WAL, readers proceed against the last committed snapshot while a writer appends. We also set busy_timeout so concurrent writers back off instead of returning SQLITE_BUSY immediately.
<?php
declare(strict_types=1);
final class TokenStore
{
public function __construct(private \PDO $db) {}
public static function open(string $path): self
{
$db = new \PDO('sqlite:' . $path, null, null, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
]);
$db->exec('PRAGMA journal_mode = WAL');
$db->exec('PRAGMA synchronous = NORMAL');
$db->exec('PRAGMA busy_timeout = 5000');
$db->exec('PRAGMA foreign_keys = ON');
return new self($db);
}
public function pdo(): \PDO
{
return $this->db;
}
}
synchronous = NORMAL is safe under WAL — a power loss can lose the last commits but cannot corrupt the database. For refresh tokens, losing the final few milliseconds of writes means at worst a client re-authenticates.
The Grace Window
Here is the mechanism that fixed our logout bug.
When a refresh token is consumed, we do not delete it. We stamp consumed_at and record replaced_by — the hash of the successor token. If a consumed token is presented again, we look at how long ago it was consumed:
- Within the grace window (we use 30 seconds): this is almost certainly a retry or a concurrent request from the same client. Return the successor token that was already issued, rather than minting another. The family stays alive. Crucially, we return the same successor, not a new one — otherwise two racing requests each get a different valid token and the chain forks.
- Outside the grace window: a token consumed 10 minutes ago being replayed now is not a network retry. Revoke the whole family.
The grace window is a security trade-off, and it should be stated plainly: an attacker who steals a refresh token and replays it within 30 seconds of legitimate use gets the same successor the real client got, and reuse detection does not fire. In exchange, every honest retry stops logging people out. Thirty seconds is longer than any realistic mobile retry chain and short enough that an attacker would need to be replaying in near-real-time — which implies they already have live access to the device or the transport, a strictly worse compromise that token rotation was never going to solve.
We additionally tighten it: within the grace window we require the user_agent_hash to match the one recorded at issue time. A retry from the same client has the same UA; a replay from an attacker's tooling usually does not.
Implementing the Refresh Endpoint
The whole operation must be atomic. The check-then-write sequence — read the token row, verify it is unconsumed, mark it consumed, insert the successor — is a classic TOCTOU if another request interleaves. In SQLite we wrap it in BEGIN IMMEDIATE, which acquires the write lock at transaction start rather than at first write, so two concurrent refreshes serialize instead of one discovering a conflict at commit time.
<?php
declare(strict_types=1);
final class RefreshTokenService
{
private const GRACE_SECONDS = 30;
private const REFRESH_TTL = 60 * 60 * 24 * 30; // 30 days
private const ACCESS_TTL = 900; // 15 minutes
public function __construct(
private \PDO $db,
private AccessTokenIssuer $access,
private \Closure $now,
) {}
/**
* @return array{access_token:string,refresh_token:string,expires_in:int}
* @throws TokenRejected
*/
public function rotate(string $presented, string $uaHash, string $region): array
{
$hash = hash('sha256', $presented);
$now = ($this->now)();
$this->db->beginTransaction(); // PDO maps this to BEGIN
$this->db->exec('ROLLBACK; BEGIN IMMEDIATE');
try {
$row = $this->fetchToken($hash);
if ($row === null) {
throw new TokenRejected('unknown_token');
}
if ($this->familyRevoked($row['family_id'])) {
throw new TokenRejected('family_revoked');
}
if ($row['revoked_at'] !== null) {
throw new TokenRejected('token_revoked');
}
if ($row['expires_at'] <= $now) {
throw new TokenRejected('token_expired');
}
if ($row['consumed_at'] !== null) {
$result = $this->handleReuse($row, $uaHash, $now);
$this->db->commit();
return $result;
}
$result = $this->issueSuccessor($row, $uaHash, $region, $now);
$this->db->commit();
return $result;
} catch (\Throwable $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
}
private function handleReuse(array $row, string $uaHash, int $now): array
{
$age = $now - (int) $row['consumed_at'];
$sameAgent = hash_equals((string) $row['user_agent_hash'], $uaHash);
if ($age > self::GRACE_SECONDS || !$sameAgent) {
$this->revokeFamily($row['family_id'], 'reuse_detected', $now);
throw new TokenRejected('reuse_detected');
}
// In-grace retry: hand back the successor that was already minted.
$successor = $this->fetchToken((string) $row['replaced_by']);
if ($successor === null || $successor['revoked_at'] !== null) {
throw new TokenRejected('successor_unavailable');
}
// We cannot return the plaintext successor from its hash, so the
// successor's plaintext is cached for exactly the grace window.
$plain = $this->graceCache->get((string) $row['replaced_by']);
if ($plain === null) {
throw new TokenRejected('grace_expired');
}
return [
'access_token' => $this->access->issue((int) $row['user_id']),
'refresh_token' => $plain,
'expires_in' => self::ACCESS_TTL,
];
}
private function issueSuccessor(array $row, string $uaHash, string $region, int $now): array
{
$plain = bin2hex(random_bytes(32));
$newHash = hash('sha256', $plain);
$this->db->prepare(
'UPDATE refresh_tokens
SET consumed_at = ?, replaced_by = ?
WHERE token_hash = ? AND consumed_at IS NULL'
)->execute([$now, $newHash, $row['token_hash']]);
$this->db->prepare(
'INSERT INTO refresh_tokens
(family_id, token_hash, user_id, issued_at, expires_at,
client_region, user_agent_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)'
)->execute([
$row['family_id'], $newHash, $row['user_id'],
$now, $now + self::REFRESH_TTL, $region, $uaHash,
]);
$this->graceCache->put($newHash, $plain, self::GRACE_SECONDS);
return [
'access_token' => $this->access->issue((int) $row['user_id']),
'refresh_token' => $plain,
'expires_in' => self::ACCESS_TTL,
];
}
private function revokeFamily(string $familyId, string $reason, int $now): void
{
$this->db->prepare(
'UPDATE token_families SET revoked_at = ?, revoke_reason = ?
WHERE family_id = ? AND revoked_at IS NULL'
)->execute([$now, $reason, $familyId]);
}
}
A note on $this->graceCache: the grace path needs to return a plaintext token, but the database only stores hashes — by design. So the successor's plaintext lives in a short-TTL cache (APCu on a single node; Redis or a Cloudflare Workers KV namespace if you run multiple origins) keyed by its hash, expiring exactly at the grace boundary. This is the one place plaintext refresh tokens exist server-side, which is why the TTL is measured in seconds and the store is memory-only, never on disk.
If you find that unacceptable, the alternative is to skip returning a token on the grace path and return only a fresh access token with a signal telling the client to keep its current refresh token. That is cleaner cryptographically but requires client cooperation, and we had shipped mobile clients we could not change.
Access Token Issuance
The access token itself is a short-lived JWT. Two details matter for a video API.
First, use EdDSA (Ed25519) or ES256 rather than HS256 if anything other than your auth service needs to verify tokens. We verify JWTs at the edge in a Cloudflare Worker before requests ever reach the origin — a symmetric key would mean shipping the signing secret to the edge, where a Worker compromise becomes a token-forgery capability. With Ed25519 the Worker holds only the public key.
Second, include a fid (family ID) claim. When a family is revoked, the edge can drop access tokens from that family immediately instead of waiting up to 15 minutes for natural expiry, by checking a small revocation set in KV.
import time
import json
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
def _b64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def issue_access_token(
private_key: Ed25519PrivateKey,
key_id: str,
user_id: int,
family_id: str,
ttl: int = 900,
) -> str:
now = int(time.time())
header = {"alg": "EdDSA", "typ": "JWT", "kid": key_id}
payload = {
"sub": str(user_id),
"fid": family_id,
"iat": now,
"nbf": now,
"exp": now + ttl,
"iss": "https://viralvidvault.com",
"aud": "vvv-video-api",
}
signing_input = "{}.{}".format(
_b64(json.dumps(header, separators=(",", ":")).encode()),
_b64(json.dumps(payload, separators=(",", ":")).encode()),
)
signature = private_key.sign(signing_input.encode())
return f"{signing_input}.{_b64(signature)}"
Keep the payload minimal. Every claim is bytes on every request, and for a video API that means bytes on every thumbnail-metadata call and every playback-position update. It also means anything you put in there is readable by anyone who intercepts the token — no email addresses, no display names, no region data. Under GDPR, a JWT carrying personal data is personal data in transit and in every client-side log that captures request headers.
Client-Side Refresh Coordination
Server-side grace handles duplicates, but the better outcome is not producing them. Clients should coalesce concurrent refreshes into a single in-flight request.
package auth
import (
"context"
"sync"
"time"
)
type TokenSet struct {
Access string
Refresh string
ExpiresAt time.Time
}
type Client struct {
mu sync.Mutex
current TokenSet
flight *refreshCall
refresh func(ctx context.Context, token string) (TokenSet, error)
}
type refreshCall struct {
done chan struct{}
set TokenSet
err error
}
// Token returns a valid access token, coalescing concurrent refreshes
// so that only one network round trip happens per expiry.
func (c *Client) Token(ctx context.Context) (string, error) {
c.mu.Lock()
if time.Until(c.current.ExpiresAt) > 30*time.Second {
tok := c.current.Access
c.mu.Unlock()
return tok, nil
}
if c.flight != nil {
call := c.flight
c.mu.Unlock()
select {
case <-call.done:
return call.set.Access, call.err
case <-ctx.Done():
return "", ctx.Err()
}
}
call := &refreshCall{done: make(chan struct{})}
c.flight = call
stale := c.current.Refresh
c.mu.Unlock()
call.set, call.err = c.refresh(context.WithoutCancel(ctx), stale)
c.mu.Lock()
if call.err == nil {
c.current = call.set
}
c.flight = nil
c.mu.Unlock()
close(call.done)
return call.set.Access, call.err
}
Three things worth copying from this:
- The refresh triggers 30 seconds before expiry, not after a 401. Refreshing reactively means at least one failed request per cycle, and on a video app that is a visible stall.
-
context.WithoutCancelon the refresh call. If the caller that happened to win the race gets cancelled — user navigates away mid-request — the refresh must still complete, because other goroutines are waiting on it and, more importantly, because a cancelled-mid-flight rotation leaves the client holding a token the server has already consumed. - Waiters block on a channel rather than each issuing their own request. This is the single highest-value change we made; it eliminated roughly 90% of duplicate refreshes before they reached the server.
Cleanup and Monitoring
Rotation generates rows. At 15-minute access tokens and a 30-day refresh TTL, an active daily user produces dozens of rows per day. Two jobs keep it bounded:
-
Expired-token purge. Delete rows where
expires_at < now - 86400. Keeping one extra day lets you investigate a reuse alert after the fact. Run it from cron, not from the request path. - Revoked-family purge. Delete families revoked more than 30 days ago, plus their tokens. Under GDPR storage limitation, retaining revoked credentials indefinitely has no justification.
After large deletes on SQLite, run PRAGMA incremental_vacuum — configure auto_vacuum = INCREMENTAL at database creation so you can reclaim pages without the full-rewrite stall of VACUUM.
For monitoring, the metric that actually matters is the reuse-detection rate. A healthy system sits near zero. We alert when it exceeds 0.1% of refreshes over an hour. In our experience every spike has had a mundane cause — a client build with broken coalescing, a load balancer replaying requests — but the one time it does not, it is a credential-theft signal you want within the hour, not at the next quarterly review.
Also track grace-window hits separately from reuse detections. A rising grace-hit rate means client coalescing is degrading, which is a bug worth fixing even though users never notice it.
What We Would Do Differently
If we were starting over: bind refresh tokens to a client-held key (DPoP, RFC 9449) from day one. Rotation plus reuse detection makes theft detectable; proof-of-possession makes a stolen token useless, because the attacker cannot produce the signature over the request that the token requires. Rotation is then a defence-in-depth layer rather than the primary control, and the grace-window trade-off stops mattering because a replayed token without the private key fails regardless.
We did not do this because our oldest mobile clients had no secure key storage path we trusted, and shipping a breaking auth change to installed apps is a months-long migration. That is a real constraint, not an excuse — but if your clients are new, skip our intermediate step.
Conclusion
The distance between "rotate refresh tokens" as advice and rotation that does not log people out is mostly concurrency handling. The pieces that made it work for us: a family ID so revocation is one write, consumed_at and replaced_by so a spent token still carries the information needed to answer a retry, a short grace window with a user-agent check, BEGIN IMMEDIATE so the check-and-rotate is genuinely atomic under WAL, and client-side coalescing to stop most duplicates ever reaching the server. Store hashes, keep the JWT payload empty of anything identifying, and put a purge job on a cron.
Our German logout reports went to zero and stayed there, and we kept the property that mattered: if someone steals a refresh token, we find out within one refresh cycle.