DEV Community

ahmet gedik
ahmet gedik

Posted on

Implementing OAuth2 Refresh Token Rotation for Smart TV Video Apps

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:

  1. Client presents refresh_token_v1
  2. Server validates, issues new access_token and refresh_token_v2
  3. Server marks refresh_token_v1 as used (not deleted)
  4. If refresh_token_v1 is 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,
    ];
}
Enter fullscreen mode Exit fullscreen mode

Two non-obvious bits:

  • Hash refresh tokens at rest so a DB dump does not leak live credentials
  • The family_id revocation 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
}
Enter fullscreen mode Exit fullscreen mode

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 exp against iat + lifetime on 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"],
    }
Enter fullscreen mode Exit fullscreen mode

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)