Smart TV video apps live in an awkward corner of OAuth2. Users sign in once with a device code, then the app runs for months without a keyboard in sight. Tokens age, refresh attempts collide, and a stolen refresh token can sit useful for a year if you don't rotate it. At DailyWatch we serve free global video discovery to TV apps that may go weeks between active sessions, so refresh-token rotation isn't optional — it's the only realistic breach detection we get.
This is a practical walkthrough of how to implement RFC 6749 rotation correctly when the client is a Roku, Tizen, or webOS app you cannot push-update reliably.
Why rotation matters more on TV than on mobile
Mobile apps can lean on hardware-backed keystores, attestation, and quick re-auth via FaceID. Smart TVs give you none of that:
- Token storage is usually plain disk in the app sandbox
- Re-authentication means typing an email with a D-pad — users will not do it twice
- App updates roll out slowly across vendors, so a leaked long-lived token stays exploitable for weeks
- Many TVs are shared in a household, and a stolen device can be physically resold with cookies intact
Rotating refresh tokens turns a stolen credential from a year-long key into a one-shot key. If the attacker uses it once, the legitimate device's next refresh will fail and the server learns there is a breach.
The rotation contract
The flow is simple on paper:
- Client presents
refresh_token_v1 - Server validates, issues new
access_tokenandrefresh_token_v2 - Server marks
refresh_token_v1as used (not deleted) - If
refresh_token_v1is presented again, revoke the entire token family
The "mark as used" step is the part most implementations get wrong. Deleting the old token means you cannot distinguish a replay attack from a network retry. Keep it, but flag it.
Server-side issuance with reuse detection
Here is a minimal PHP handler. Tokens are stored with a family_id so a single compromise kills the whole chain:
<?php
function rotateRefreshToken(PDO $db, string $presented): array {
$stmt = $db->prepare(
'SELECT id, family_id, user_id, used_at, revoked_at
FROM refresh_tokens WHERE token_hash = ? LIMIT 1'
);
$stmt->execute([hash('sha256', $presented)]);
$token = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$token || $token['revoked_at']) {
throw new TokenError('invalid_grant');
}
if ($token['used_at']) {
// Replay detected — burn the entire family
$db->prepare('UPDATE refresh_tokens SET revoked_at = ? WHERE family_id = ?')
->execute([time(), $token['family_id']]);
throw new TokenError('invalid_grant', 'token_reuse_detected');
}
$newRefresh = bin2hex(random_bytes(32));
$db->beginTransaction();
$db->prepare('UPDATE refresh_tokens SET used_at = ? WHERE id = ?')
->execute([time(), $token['id']]);
$db->prepare(
'INSERT INTO refresh_tokens (token_hash, family_id, user_id, issued_at)
VALUES (?, ?, ?, ?)'
)->execute([hash('sha256', $newRefresh), $token['family_id'], $token['user_id'], time()]);
$db->commit();
return [
'access_token' => issueAccessJwt($token['user_id']),
'refresh_token' => $newRefresh,
'expires_in' => 3600,
];
}
Two non-obvious bits:
- Hash refresh tokens at rest so a DB dump does not leak live credentials
- The
family_idrevocation is the whole point — it punishes the attacker and the legitimate device, forcing a re-login on the real TV. That is the alarm.
Race conditions: the TV's worst enemy
Smart TVs love to fire multiple HTTP requests at once when waking from standby. If your access token expired overnight, three parallel video-detail calls will trigger three parallel refresh attempts with the same refresh token. Without coordination, two of them get marked as replays and you wipe the family.
The client must serialize refreshes. In Go, a singleflight group is the cleanest tool:
var refreshGroup singleflight.Group
func (c *Client) AccessToken(ctx context.Context) (string, error) {
if tok := c.cached(); !tok.NearExpiry() {
return tok.AccessToken, nil
}
v, err, _ := refreshGroup.Do("refresh", func() (interface{}, error) {
return c.doRefresh(ctx)
})
if err != nil {
return "", err
}
return v.(*Token).AccessToken, nil
}
On the server, treat a refresh issued within the last ~10 seconds as idempotent for the same token hash — return the previously issued pair instead of rotating again. That covers the legitimate retry case (TV's WiFi blip, request times out, client retries) without weakening the replay detector.
Storage, clocks, and graceful failure
A few things bite specifically on TV hardware:
-
Clock drift. Cheap TVs boot with a 2010 date until NTP succeeds. Validate
expagainstiat + lifetimeon the client rather than wall clock alone. - Storage atomicity. If the app crashes mid-rotation, the TV may have written the new access token but not the new refresh token. Always persist the refresh token first, then the access token.
- Forced re-auth UX. When the family is revoked, your TV app should display a short device code (RFC 8628) and let the user re-link on their phone — never a D-pad keyboard.
A tiny Python helper for the device-code re-link prompt:
import requests
def begin_device_relink(client_id: str) -> dict:
resp = requests.post(
"https://auth.example.com/device/code",
data={"client_id": client_id, "scope": "video.read"},
timeout=5,
)
resp.raise_for_status()
data = resp.json()
return {
"show_code": data["user_code"],
"show_url": data["verification_uri"],
"poll_in": data["interval"],
}
What we shipped, what we would change
Rotation gave us one concrete win: a measurable count of token_reuse_detected events per week, which is now our primary signal for stolen-device fraud. What we would change if starting over: store the device's user-agent and IP /24 alongside each refresh token, and treat a refresh from a wildly different network as a soft challenge rather than a hard revoke. Smart TVs travel — vacation homes, hotels, dorm moves — and the false-positive rate on hard revocation is higher than the spec docs suggest.
Rotation is cheap to add and pays for itself the first time you see a reuse event in production.
Top comments (0)