A mobile client for our video catalog was silently logging users out every 15 minutes. The access token expired, the client hit /refresh, got a new pair, and everything looked fine — until a second copy of that same refresh token showed up from a device the user had traded in three weeks earlier. That old token still worked. It had been valid the entire time, sitting in a screenshot in someone's cloud backup, because we issued long-lived refresh tokens and never invalidated the ones we replaced.
That is the exact failure mode refresh-token rotation exists to close. At DailyWatch we serve a public video-discovery API to web, mobile, and a handful of third-party integrators, and "a leaked refresh token is valid until it expires" is not an acceptable security posture when tokens live for 30 days. This article walks through how we built rotation with reuse detection on a boring stack — PHP 8.4 and SQLite, no Redis, no external session store — and how the clients (a Go daemon and a Python SDK) have to behave for it to actually work.
The problem with long-lived refresh tokens
The standard OAuth-style setup is two tokens:
- A short-lived access token (JWT, 15 minutes) that the API validates on every request with a signature check and no database lookup.
- A long-lived refresh token (30 days) the client exchanges for a fresh access token when the old one expires.
The access token being stateless is the whole point — it keeps the hot path fast. On a video API where an autoplay session fires dozens of requests a minute, you do not want a database round-trip per request just to check auth. The refresh token is the opposite: it is used rarely, so you can afford to hit storage, and that is where you get to be stateful and paranoid.
Without rotation, a refresh token is a bearer credential with a month-long lifetime. If it leaks — logs, a backup, a shared device, a misconfigured proxy that cached a response — the attacker has a month of access and you have no signal that anything is wrong. Rotation fixes both halves:
- Every refresh returns a new refresh token and invalidates the old one. A stolen token is only useful until the legitimate client next refreshes.
- Reuse of an already-rotated token is treated as a breach. If a token that was already swapped out gets presented again, either the client is buggy or someone cloned it. Either way, you kill the whole token family.
That second rule — reuse detection — is what turns rotation from a minor hardening step into an actual intrusion alarm.
Data model on SQLite
You do not need a fancy store for this. The refresh tokens live in one table. The critical design choice is that tokens belong to a family: the first login creates a family, and every rotation within that family shares a family_id. When we detect reuse, we revoke the family, not just one row.
We never store the raw refresh token. We store a SHA-256 hash of it, exactly like a password, so a database leak does not hand out working tokens.
CREATE TABLE refresh_tokens (
id INTEGER PRIMARY KEY,
family_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
issued_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER, -- set when this token is rotated away
revoked INTEGER NOT NULL DEFAULT 0,
replaced_by TEXT, -- hash of the token that superseded this one
client_label TEXT -- "ios-app", "partner-crawler", etc.
);
CREATE INDEX idx_rt_family ON refresh_tokens(family_id);
CREATE INDEX idx_rt_expiry ON refresh_tokens(expires_at);
A few things worth calling out:
-
token_hashisUNIQUEso a replayed insert can never create a duplicate live token. -
used_atbeing non-null is the marker that says "this token has already been rotated." Presenting a used token is the reuse signal. -
revokedlets us tombstone a whole family in oneUPDATE. -
expires_atandissued_atare Unix timestamps — SQLite has no native datetime type worth fighting over, and integer comparison is trivial to index.
On LiteSpeed with SQLite in WAL mode this table handles our refresh volume without breaking a sweat. Refreshes are infrequent by design, so even a few thousand writes an hour is nothing. We run PRAGMA journal_mode=WAL and PRAGMA busy_timeout=5000 so concurrent refreshes from different clients do not collide on the write lock.
Issuing the first token pair
Login is where a family is born. We generate a cryptographically random refresh token, hash it for storage, and hand the raw value back to the client exactly once. The access token is a short JWT signed with HS256 (we use a per-environment secret; RS256 is worth it once you have multiple verifying services, but for a single API it is overkill).
<?php
declare(strict_types=1);
final class TokenService
{
private const ACCESS_TTL = 900; // 15 minutes
private const REFRESH_TTL = 2592000; // 30 days
public function __construct(
private readonly PDO $db,
private readonly string $jwtSecret,
) {}
/** @return array{access:string, refresh:string} */
public function issuePair(int $userId, string $clientLabel): array
{
$familyId = bin2hex(random_bytes(16));
return $this->mintForFamily($userId, $familyId, $clientLabel);
}
/** @return array{access:string, refresh:string} */
private function mintForFamily(int $userId, string $familyId, string $label): array
{
$now = time();
$rawToken = bin2hex(random_bytes(32)); // 256 bits of entropy
$hash = hash('sha256', $rawToken);
$stmt = $this->db->prepare(
'INSERT INTO refresh_tokens
(family_id, user_id, token_hash, issued_at, expires_at, client_label)
VALUES (:fam, :uid, :hash, :iat, :exp, :label)'
);
$stmt->execute([
':fam' => $familyId,
':uid' => $userId,
':hash' => $hash,
':iat' => $now,
':exp' => $now + self::REFRESH_TTL,
':label' => $label,
]);
return [
'access' => $this->signAccessToken($userId, $now),
'refresh' => $rawToken . '.' . $familyId, // family is opaque to client
];
}
private function signAccessToken(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' => 'dailywatch-api',
]));
$sig = $this->b64(hash_hmac('sha256', "$header.$payload", $this->jwtSecret, true));
return "$header.$payload.$sig";
}
private function b64(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}
The refresh token we return to the client is rawToken.familyId. The client treats the whole thing as opaque — it never parses it — but shipping the family id alongside the secret lets the server look up the family cheaply on refresh without a second index scan. The raw secret half is what we hash and compare; the family id is just routing.
The rotation endpoint with reuse detection
This is the core of the whole scheme. When a refresh comes in, there are exactly three outcomes:
- Valid, unused, unexpired token → rotate: mark it used, mint a new pair in the same family.
- Unknown token → reject. Never seen it, nothing to do.
- Known but already-used token → reuse detected. Revoke the entire family and reject.
Case 3 is the alarm. A well-behaved client never presents a token twice, because as soon as it gets a new one it throws the old away. If the server sees an old token again, it means two parties hold the same credential — the legitimate client and a thief, or a client that forked. We cannot tell which, so we assume the worst and nuke the family. The real user gets logged out and has to re-authenticate, which is a mild annoyance that beats leaving an attacker with a live session.
<?php
final class RefreshError extends RuntimeException {}
trait RotationEndpoint
{
/** @return array{access:string, refresh:string} */
public function rotate(string $presented): array
{
[$rawToken, $familyId] = array_pad(explode('.', $presented, 2), 2, '');
if ($rawToken === '' || $familyId === '') {
throw new RefreshError('malformed token');
}
$hash = hash('sha256', $rawToken);
$now = time();
$this->db->beginTransaction();
try {
$row = $this->lockRow($hash);
if ($row === null) {
// Unknown token. Could be random garbage, or a family we already
// revoked and purged. Reject without leaking which.
throw new RefreshError('invalid token');
}
if ((int) $row['revoked'] === 1) {
throw new RefreshError('token revoked');
}
if ($row['used_at'] !== null) {
// REUSE. This token was already rotated away. Burn the family.
$this->revokeFamily($row['family_id']);
$this->db->commit();
error_log("refresh reuse detected family={$row['family_id']} "
. "user={$row['user_id']} label={$row['client_label']}");
throw new RefreshError('token reuse detected; family revoked');
}
if ((int) $row['expires_at'] < $now) {
throw new RefreshError('token expired');
}
// Happy path: mark this token used, mint a successor in the family.
$pair = $this->mintForFamily(
(int) $row['user_id'], $row['family_id'], $row['client_label']
);
$newHash = hash('sha256', explode('.', $pair['refresh'], 2)[0]);
$upd = $this->db->prepare(
'UPDATE refresh_tokens
SET used_at = :now, replaced_by = :next
WHERE token_hash = :hash'
);
$upd->execute([':now' => $now, ':next' => $newHash, ':hash' => $hash]);
$this->db->commit();
return $pair;
} catch (Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
private function lockRow(string $hash): ?array
{
// SQLite has no SELECT ... FOR UPDATE, but the surrounding write
// transaction (BEGIN IMMEDIATE via the first write) serializes refreshes
// for a given token. We escalate to a write lock explicitly.
$stmt = $this->db->prepare(
'SELECT * FROM refresh_tokens WHERE token_hash = :hash'
);
$stmt->execute([':hash' => $hash]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
private function revokeFamily(string $familyId): void
{
$stmt = $this->db->prepare(
'UPDATE refresh_tokens SET revoked = 1 WHERE family_id = :fam'
);
$stmt->execute([':fam' => $familyId]);
}
}
The transaction matters. Two requests racing with the same refresh token — which happens more than you would think when a flaky mobile network makes a client retry — must not both succeed. On SQLite, the first UPDATE inside the transaction takes a write lock; the second request blocks on busy_timeout, then reads the now-used row and correctly trips reuse detection. In WAL mode this is clean as long as every refresh path goes through a write transaction. If you are on Postgres, swap lockRow for a real SELECT ... FOR UPDATE.
One subtlety: legitimate retries after a network drop will look like reuse and revoke the family. In practice we accept this because it is rare and safe, but if it becomes a support problem, a common softening is a short grace window — if a used token is presented within a few seconds and its replaced_by successor has not itself been used, return the already-minted successor instead of revoking. That trades a little security for a lot fewer spurious logouts. We started strict and only loosened it after measuring.
What the client has to do
Rotation is a two-party contract. The server can be perfect, but if the client is sloppy about persisting the new token, it will present a stale one and get its family revoked. The client rules are simple but non-negotiable:
- Persist the new refresh token before using the new access token. If you crash between refresh and save, you must be able to recover with the newest token, not the one you sent.
- Serialize refreshes. If ten API calls all get a 401 at once, do not fire ten concurrent refreshes. One refresh, everyone else waits for the result.
- On a 401 from refresh itself, log out. The family is gone; there is no recovery except re-authentication.
Here is the serialized refresh in a Go client — a single-flight guard so concurrent 401s collapse into one refresh:
package apiclient
import (
"context"
"encoding/json"
"net/http"
"sync"
"time"
)
type tokenStore interface {
Load() (access, refresh string)
Save(access, refresh string) error
}
type Client struct {
http *http.Client
baseURL string
store tokenStore
refresh sync.Mutex // serializes token refresh
inflight *sync.Once
}
type pair struct {
Access string `json:"access"`
Refresh string `json:"refresh"`
}
// Do performs a request, refreshing once on 401 and retrying.
func (c *Client) Do(ctx context.Context, req *http.Request) (*http.Response, error) {
access, _ := c.store.Load()
req.Header.Set("Authorization", "Bearer "+access)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusUnauthorized {
return resp, nil
}
resp.Body.Close()
if err := c.refreshOnce(ctx); err != nil {
return nil, err // family likely revoked -> caller must re-auth
}
access, _ = c.store.Load()
req.Header.Set("Authorization", "Bearer "+access)
return c.http.Do(req)
}
func (c *Client) refreshOnce(ctx context.Context) error {
c.refresh.Lock()
defer c.refresh.Unlock()
// Another goroutine may have refreshed while we waited on the lock.
// A cheap way to detect that: re-check whether the current access token
// still parses as expired. Omitted here for brevity; in production we
// compare the refresh token we loaded before vs. after acquiring the lock.
_, refresh := c.store.Load()
body, _ := json.Marshal(map[string]string{"refresh": refresh})
req, _ := http.NewRequestWithContext(
ctx, http.MethodPost, c.baseURL+"/auth/refresh",
bytesReader(body),
)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ErrReauth // 401 here means the family is dead
}
var p pair
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
return err
}
// Persist BEFORE we return, so a crash cannot lose the new token.
return c.store.Save(p.Access, p.Refresh)
}
func (c *Client) SetHTTP(h *http.Client, t time.Duration) {
c.http = h
c.http.Timeout = t
}
The sync.Mutex around refresh is the part people skip and then spend a week debugging. Without it, a burst of expired-token requests each triggers its own /refresh, they rotate against each other, and the family self-destructs from what looks exactly like an attack. Single-flight the refresh and the whole class of bug disappears.
Cleaning up and observing
Expired and revoked rows accumulate. We do not delete on the hot path — that would add write contention to refresh. Instead a cron sweep runs hourly and prunes anything long dead, keeping recently-revoked families around briefly so reuse detection can still fire on a stolen token from a family we just killed.
<?php
// cron/prune_tokens.php — run hourly
$db->exec(<<<'SQL'
DELETE FROM refresh_tokens
WHERE expires_at < strftime('%s','now') - 86400 -- expired > 1 day
OR (revoked = 1 AND used_at < strftime('%s','now') - 604800)
SQL);
$db->exec('PRAGMA wal_checkpoint(TRUNCATE)');
$db->exec('PRAGMA optimize');
The observability side is one log line, but it is the most valuable one in the system: the refresh reuse detected message. Every time it fires, either a client has a serialization bug or a token genuinely leaked. We alert on the rate of it — a single event is usually a flaky client, but a spike across many families at once is the shape of a credential dump being replayed. Piping that log through Cloudflare's request analytics lets us correlate the reuse events with source IPs and user agents, which is how we told "our own retry bug" apart from "someone is walking a stolen token list" the one time it actually mattered.
Wrapping up
Refresh-token rotation is not exotic and it does not need heavy infrastructure. The whole thing is one table, a transaction, and a hash comparison. The parts that actually earn their keep:
- Store hashes, never raw tokens — a database leak should not be a session leak.
- Rotate on every refresh so a stolen token has a short useful life.
- Group tokens into families and revoke the whole family on reuse — that turns rotation from hardening into a live intrusion signal.
- Serialize refreshes on the client or you will manufacture false-positive breaches out of ordinary network retries.
- Sweep dead rows off the hot path, keep revoked families around just long enough for reuse detection to stay useful.
We shipped this on PHP 8.4 and SQLite because that is what our API already runs on, and it has comfortably handled every client we point at it without a Redis or a session service in sight. The bearer-token-valid-for-a-month problem that started this whole thing is simply gone: a leaked refresh token now survives exactly until the real client refreshes once, and the moment the leaked copy is used, the family dies and we get paged. Start strict, measure your false positives, and only loosen with a grace window if the data tells you to.
Top comments (0)