DEV Community

ahmet gedik
ahmet gedik

Posted on

OAuth2 Refresh-Token Rotation for Smart TV Video Apps: A GDPR-Safe Pattern

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 localStorage under 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/refresh returns a new RT and increments generation.
  • 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
}
Enter fullscreen mode Exit fullscreen mode

A few details worth calling out:

  • RotateRefreshToken is 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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 (Tizen b2bcontrol, webOS securityservice), 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
Enter fullscreen mode Exit fullscreen mode

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)