Smart TV video apps have a uniquely hostile environment for OAuth2 token management. Devices sleep for days, wake into stale sessions, run on locked-down WebKit runtimes with quirky storage, and they are used by people who never want to see a login screen again. At ViralVidVault we run a fleet of smart TV clients across Europe, and getting refresh-token rotation right has been one of our highest-leverage security investments — both for credential hygiene and for the GDPR audit trail we have to keep.
This post walks through the rotation pattern we converged on, the smart TV-specific traps that bit us, and how we detect token theft without nuking honest users.
Why Smart TVs Break Naive Token Strategies
The default OAuth2 advice — short-lived access token, long-lived refresh token, refresh on 401 — assumes a well-behaved client. Smart TVs are not well-behaved clients. A few quirks we have documented across Tizen, webOS, Android TV, and Vidaa:
- Devices suspend for days; on resume your access token is always expired and often your refresh token is close to it.
- Storage APIs differ wildly. Some TVs evict
localStorageunder memory pressure. Others persist forever but expose contents to other apps. - WebKit on older TVs runs your refresh flow twice if two XHRs hit 401 in the same tick.
- Users almost never log out. A device might run the same session for a year, which is exactly the window in which a stolen refresh token does damage.
The fix is rotation: every refresh mints a fresh access token and a fresh refresh token, the old refresh token is invalidated immediately, and reuse of a revoked token revokes the entire token family.
The Rotation Contract
We treat each refresh-token lineage as a family with a stable family_id and a monotonic generation counter. The rules:
- Every successful
/oauth/refreshreturns a new RT and incrementsgeneration. - The previous RT becomes immediately invalid, with a short grace window (we use 30 seconds) to absorb network retries.
- If a revoked RT is ever presented after the grace window, every active token in that family is revoked and the user must re-authenticate.
- Token families are scoped to a single device install. Re-authenticating mints a new family.
This last point matters for GDPR: families let us honour erasure requests by revoking everything for a given user-device pair in one DB write, with a clean audit record of when each generation was issued and rotated.
Server-Side Rotation in Go
Here is the core of our rotation handler, with the family bookkeeping inlined:
func (s *AuthServer) Refresh(ctx context.Context, presented string) (*TokenPair, error) {
rt, err := s.store.LookupRefreshToken(ctx, presented)
if err != nil {
return nil, ErrInvalidToken
}
if rt.RevokedAt != nil {
if time.Since(*rt.RevokedAt) > 30*time.Second {
// Reuse after grace window: assume theft, burn the family.
_ = s.store.RevokeFamily(ctx, rt.FamilyID, "reuse_detected")
s.audit.Log(ctx, "rt_reuse", rt.UserID, rt.DeviceID, rt.FamilyID)
return nil, ErrTokenReuse
}
// Inside grace window: reissue the same next-generation pair.
return s.store.LoadGeneration(ctx, rt.FamilyID, rt.Generation+1)
}
next := TokenPair{
AccessToken: mintJWT(rt.UserID, rt.DeviceID, 15*time.Minute),
RefreshToken: randomToken(48),
FamilyID: rt.FamilyID,
Generation: rt.Generation + 1,
ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
}
if err := s.store.RotateRefreshToken(ctx, rt, next); err != nil {
return nil, err
}
return &next, nil
}
A few details worth calling out:
-
RotateRefreshTokenis a single transaction that marks the old row revoked and inserts the new one. If two refreshes race, only one wins, and the loser hits the grace-window branch. - We persist the issued pair before returning so the grace-window replay is deterministic. Re-minting on every replay would let an attacker farm tokens.
- Access tokens are 15 minutes; refresh tokens 30 days but rotated on every use, so in practice they live for hours.
Single-Flight Refresh on the Client
The WebKit-fires-two-refreshes-in-the-same-tick problem is real. Our backend-for-frontend serves a tiny JS shim that funnels every refresh through a single in-flight promise. The PHP side enforces the same idempotency server-side via a short-lived lock keyed on the presented refresh token:
public function refresh(string $presented): array
{
$lockKey = 'rt:' . hash('sha256', $presented);
$lock = $this->cache->acquire($lockKey, ttl: 5);
if (!$lock) {
usleep(150000);
$cached = $this->cache->get($lockKey . ':result');
if ($cached) {
return $cached;
}
throw new RefreshInFlightException();
}
try {
$pair = $this->oauth->refresh($presented);
$this->cache->set($lockKey . ':result', $pair, ttl: 30);
return $pair;
} finally {
$this->cache->release($lockKey);
}
}
The 30-second result cache mirrors the grace window on the auth server and absorbs the retry storms that happen when a TV wakes from suspend.
Storing Tokens on a TV You Do Not Trust
Refresh tokens are bearer credentials; if they leak, rotation is your only safety net. On smart TVs we:
- Never use plain
localStorage. We use the platform secure storage where it exists (Tizenb2bcontrol, webOSsecurityservice), and a hardware-bound AES-GCM wrap of the token where it does not. - Bind the wrap key to a device fingerprint (model + serial + install ID) so a token dumped from device A cannot be replayed from device B without also forging the fingerprint.
- Set a hard floor on storage failures: if the secure store returns garbage twice in a row, force re-authentication rather than silently downgrading to plaintext.
Detecting Theft Without Burning Honest Users
The reuse-detection rule sounds aggressive — one stale request and a whole device gets logged out. In practice the grace window plus client-side single-flight refreshes drops false positives to a handful per million refreshes. When we do trigger a family revoke we feed the event into a small Python clustering job:
def cluster_revokes(events: list[RevokeEvent]) -> list[Cluster]:
by_user: dict[str, list[RevokeEvent]] = {}
for e in events:
by_user.setdefault(e.user_id, []).append(e)
clusters = []
for user_id, evs in by_user.items():
evs.sort(key=lambda x: x.ts)
latest = evs[-1].ts
window = [e for e in evs if (latest - e.ts).total_seconds() < 3600]
distinct_devices = {e.device_id for e in window}
if len(distinct_devices) >= 3:
clusters.append(Cluster(user_id, window, len(distinct_devices)))
return clusters
Three or more device revokes for one user within an hour is a strong signal of credential stuffing or a leaked token store. The cluster goes to our abuse team with the minimal data needed under GDPR Article 6(1)(f) legitimate-interest basis; raw IPs are hashed after 30 days and rotation events are deleted with the user on erasure.
What Rotation Is Not
Rotation is not a replacement for short-lived access tokens, proper TLS pinning, or PKCE on the initial device flow. It is a containment mechanism: it caps the blast radius of a leaked refresh token to one rotation cycle and gives you a signal that a leak happened at all. Pair it with the rest of the OAuth2 hygiene checklist and your smart TV fleet becomes one of the more boring parts of your security posture — which, for a video platform serving millions of European living rooms, is exactly what you want.
Top comments (0)