A viewer opens our Android TV app, lands on a wall of trending videos pulled from Japan, Korea, and Taiwan, and taps Sign in to sync a watchlist between their phone and the living room. Then they hit the wall every TV developer eventually meets: there is no keyboard. There is a D-pad, an OK button, and maybe a voice remote that mishears half of what it's told. Asking someone to peck out an email and a strong password on a grid of on-screen letters is how you lose a login. At TopVideoHub we run this across a lot of low-end TV hardware in the Asia-Pacific market, and the math is unforgiving: every extra second on the sign-in screen measurably drops completion.
The OAuth2 Device Authorization Grant (RFC 8628) is the fix. The TV shows a short human-readable code, the user finishes login on the device already in their hand, and the TV quietly polls a backend until approval lands. This article walks through a real implementation on PHP 8.4 and SQLite, the parts the RFC glosses over, and the operational details that bite you behind LiteSpeed and Cloudflare.
Why the authorization code flow breaks on a TV
The normal browser-based authorization code flow assumes three things a TV does not reliably have: a capable web browser, a usable text-input method, and the ability to receive a redirect back to the application. A set-top box might ship a WebView, but bouncing the user out to an identity provider, having them type credentials with a remote, and catching the redirect URI in a native app is a fragile chain. Each link is a place to fail.
The device flow's core trick is decoupling the device that consumes the token from the device that grants it. The TV never sees a password. It asks the backend for a pair of codes, displays one to the human, and then does nothing but poll. The actual authentication happens in a real browser on a phone or laptop, where autofill, password managers, and even passkeys already work. The TV is reduced to a polling client, which is exactly what a constrained device is good at.
The device flow in four messages
Strip away the jargon and the whole protocol is four messages:
-
Device authorization request. The TV POSTs its
client_idand requestedscopeto the device endpoint. The server returns adevice_code(for the machine), auser_code(for the human), averification_uri, a pollinterval, and anexpires_in. -
Display. The TV renders the
user_codeandverification_urion screen, ideally beside a QR code so a phone camera can jump straight to the page. - User approval. On their phone, the user opens the verification URI, logs in normally, confirms the code they see on the TV, and approves the requested scopes.
-
Polling. Meanwhile the TV repeatedly POSTs the
device_codeto the token endpoint. It getsauthorization_pendinguntil the human finishes, then a real access token.
Two codes, two endpoints, one polling loop. The subtlety is entirely in the edges: code entropy, replay protection, the slow_down back-off, and making sure no layer of your stack caches the token endpoint.
The data model
Everything hangs off a single device_auth table. We store the SHA-256 of the device_code rather than the code itself, because that value is a bearer secret — if your database leaks, you don't want raw, still-valid codes in it. The user_code stays in plaintext because it is short-lived, low-entropy by design, and has to be matched against what a human types.
CREATE TABLE device_auth (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_code TEXT NOT NULL UNIQUE, -- sha256 of the real code
user_code TEXT NOT NULL UNIQUE, -- WDXR-7K2P shown on the TV
client_id TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending', -- pending|approved|denied
user_id INTEGER, -- set when a human approves
interval_s INTEGER NOT NULL DEFAULT 5,
last_poll_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX idx_device_user_code ON device_auth(user_code);
CREATE INDEX idx_device_expires ON device_auth(expires_at);
SQLite is more than enough here. These rows live for fifteen minutes and then get swept by a cron job. We already run SQLite as the primary store across the site, so the auth flow inherits the same backup and deployment story instead of dragging in a new dependency. The last_poll_at and interval_s columns exist purely to implement back-off, which I'll come back to.
Issuing a device code
The device endpoint generates both codes and persists the row. The user_code is the part real humans interact with, so its alphabet matters: drop 0/O, 1/I, and 5/S-style look-alikes, keep it uppercase, and group it with a dash so it reads cleanly from across a room.
<?php
declare(strict_types=1);
// POST /oauth/device/code (client_id=...&scope=watchlist+profile)
function randomToken(int $bytes): string {
return bin2hex(random_bytes($bytes));
}
function userCode(): string {
// No ambiguous characters; grouped as WDXR-7K2P for easy reading.
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$code = '';
for ($i = 0; $i < 8; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
return substr($code, 0, 4) . '-' . substr($code, 4, 4);
}
$clientId = $_POST['client_id'] ?? '';
$scope = $_POST['scope'] ?? '';
header('Content-Type: application/json');
if ($clientId === '') {
http_response_code(400);
echo json_encode(['error' => 'invalid_request']);
exit;
}
$deviceCode = randomToken(40);
$userCode = userCode();
$now = time();
$expiresIn = 900; // 15 minutes
$interval = 5; // baseline poll gap, seconds
$db = new PDO('sqlite:' . __DIR__ . '/../data/auth.db');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$stmt = $db->prepare(
'INSERT INTO device_auth
(device_code, user_code, client_id, scope, status, interval_s, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
hash('sha256', $deviceCode), // store only the hash
$userCode,
$clientId,
$scope,
'pending',
$interval,
$now,
$now + $expiresIn,
]);
echo json_encode([
'device_code' => $deviceCode,
'user_code' => $userCode,
'verification_uri' => 'https://topvideohub.com/activate',
'verification_uri_complete' => 'https://topvideohub.com/activate?code=' . $userCode,
'expires_in' => $expiresIn,
'interval' => $interval,
], JSON_UNESCAPED_SLASHES);
A few decisions worth flagging. The user_code here has 32^8 ≈ 1.1 trillion possibilities, which is comfortable only if you rate-limit the verification endpoint — short codes are inherently guessable at scale, so brute-force protection lives on the approval side, not in the entropy. The verification_uri_complete field is a convenience that pre-fills the code from a QR scan, but it must never auto-approve; the human still has to confirm. And the fifteen-minute expires_in is a deliberate trade-off between giving a slow user time to find their phone and keeping abandoned codes from piling up.
The verification page
This is the human half and it deserves the same care as the machine half, even though it looks like an ordinary web page. The flow is: the user authenticates normally (the same session login the rest of the site uses), the page looks up the user_code they entered, and it shows exactly which scopes the TV is asking for. On approval, you set status = 'approved' and stamp the authenticated user_id onto the row; on rejection, status = 'denied'.
Two rules that are easy to get wrong here. First, never approve a code that has already expired — check expires_at server-side, not just in the UI. Second, throttle this endpoint hard. Because the user_code is short, an attacker who can submit codes quickly could try to hijack a pending session. We rate-limit by IP and by authenticated user, and we lock a user_code after a small number of failed match attempts. The code being typed by a logged-in human on a phone is the trust anchor of the whole flow; protect it accordingly.
Token polling and the slow_down dance
The token endpoint is where most implementations cut corners. It has to return precise RFC error codes, because the client's behavior branches on them: authorization_pending means keep waiting, slow_down means back off, access_denied means stop, and expired_token means start over. It also has to enforce the poll interval, because a naive TV client will hammer the endpoint, and the server is responsible for pushing back.
<?php
declare(strict_types=1);
// POST /oauth/token
// grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=...&client_id=...
header('Content-Type: application/json');
header('Cache-Control: no-store'); // never cache token responses
$grant = $_POST['grant_type'] ?? '';
$deviceCode = $_POST['device_code'] ?? '';
if ($grant !== 'urn:ietf:params:oauth:grant-type:device_code' || $deviceCode === '') {
http_response_code(400);
echo json_encode(['error' => 'invalid_request']);
exit;
}
$db = new PDO('sqlite:' . __DIR__ . '/../data/auth.db');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$row = $db->prepare('SELECT * FROM device_auth WHERE device_code = ? LIMIT 1');
$row->execute([hash('sha256', $deviceCode)]);
$auth = $row->fetch(PDO::FETCH_ASSOC);
$now = time();
if (!$auth || $now > (int) $auth['expires_at']) {
http_response_code(400);
echo json_encode(['error' => 'expired_token']);
exit;
}
// Back-off: if the device polls faster than its interval, raise the interval
// and tell it to slow down.
$minGap = (int) $auth['interval_s'];
$lastPoll = (int) $auth['last_poll_at'];
if ($lastPoll > 0 && ($now - $lastPoll) < $minGap) {
$db->prepare('UPDATE device_auth SET interval_s = interval_s + 5 WHERE id = ?')
->execute([$auth['id']]);
http_response_code(400);
echo json_encode(['error' => 'slow_down']);
exit;
}
$db->prepare('UPDATE device_auth SET last_poll_at = ? WHERE id = ?')
->execute([$now, $auth['id']]);
switch ($auth['status']) {
case 'pending':
http_response_code(400);
echo json_encode(['error' => 'authorization_pending']);
break;
case 'denied':
http_response_code(400);
echo json_encode(['error' => 'access_denied']);
break;
case 'approved':
$accessToken = bin2hex(random_bytes(32));
$refreshToken = bin2hex(random_bytes(32));
$db->prepare(
'INSERT INTO tokens (user_id, access_token, refresh_token, scope, expires_at)
VALUES (?, ?, ?, ?, ?)'
)->execute([
$auth['user_id'],
hash('sha256', $accessToken),
hash('sha256', $refreshToken),
$auth['scope'],
$now + 3600,
]);
// Burn the device_code so it can never be replayed.
$db->prepare('DELETE FROM device_auth WHERE id = ?')->execute([$auth['id']]);
echo json_encode([
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'token_type' => 'Bearer',
'expires_in' => 3600,
'scope' => $auth['scope'],
]);
break;
}
Note the three defensive moves: token hashes go into the database (the raw values are returned exactly once), the device_code row is deleted the instant a token is issued so a replayed poll can't mint a second token, and the slow_down branch actually raises the stored interval rather than just complaining. A polite back-off that the server doesn't enforce is not a back-off.
The TV client
The device side is simple by design. Request codes, render them, sleep, poll, and honor whatever the server tells you. Here it is in Go, which is a common choice for Tizen, webOS, and Android TV sidecar services:
package main
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
)
type deviceResp struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
type tokenResp struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
}
const base = "https://topvideohub.com/oauth"
func main() {
dr := requestCode()
// Render dr.UserCode + dr.VerificationURI on screen (big font + QR code).
println("Go to", dr.VerificationURI, "and enter", dr.UserCode)
interval := time.Duration(dr.Interval) * time.Second
deadline := time.Now().Add(time.Duration(dr.ExpiresIn) * time.Second)
for time.Now().Before(deadline) {
time.Sleep(interval)
tok, status := poll(dr.DeviceCode)
switch {
case status == "authorization_pending":
continue
case status == "slow_down":
interval += 5 * time.Second // honour the server back-off
case tok.AccessToken != "":
println("signed in")
return
default:
println("login failed:", status)
return
}
}
println("device code expired, restart sign-in")
}
func requestCode() deviceResp {
form := url.Values{"client_id": {"tv-app"}, "scope": {"watchlist profile"}}
resp, _ := http.PostForm(base+"/device/code", form)
defer resp.Body.Close()
var dr deviceResp
json.NewDecoder(resp.Body).Decode(&dr)
return dr
}
func poll(deviceCode string) (tokenResp, string) {
form := url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"device_code": {deviceCode},
"client_id": {"tv-app"},
}
resp, _ := http.Post(base+"/token", "application/x-www-form-urlencoded",
strings.NewReader(form.Encode()))
defer resp.Body.Close()
var tr tokenResp
json.NewDecoder(resp.Body).Decode(&tr)
if tr.Error != "" {
return tr, tr.Error
}
return tr, "ok"
}
The loop respects three signals from the server: the initial interval, the slow_down bump, and the overall expires_in deadline. A well-behaved client never decides on its own how often to poll — that decision belongs to the backend, which is the only side that knows how loaded it is.
Operational details behind Cloudflare and LiteSpeed
This is where a textbook-correct implementation still fails in production. Polling generates a steady stream of identical-looking requests, and your caching layers will happily cache the wrong thing.
-
Never cache the token endpoint. It returns
Cache-Control: no-store, but defense in depth matters. On Cloudflare we add a cache rule to bypass/oauth/*entirely. Because the site runs Cloudflare in Flexible mode, a stale cachedauthorization_pendingwould leave a TV polling forever even after approval — so this rule is not optional. -
Exclude /oauth from the LiteSpeed page cache. The same logic applies to the origin-side cache. We add
/oauthto the cache-exclude list so LiteSpeed never serves a stored response for a dynamic token check. -
Rate-limit by client and IP. Beyond the per-row
slow_down, put a coarse rate limit on both endpoints. The device endpoint can be abused to spam code creation; the verification endpoint must be throttled to defeatuser_codeguessing. -
Sweep expired rows. A small cron
DELETE FROM device_auth WHERE expires_at < strftime('%s','now')keeps the table tiny and removes abandoned codes promptly.
Security details that matter
The device flow's convenience comes from sending a short code over a side channel, and that is also its main attack surface. Treat the user_code as the weak link: keep it short-lived, rate-limit and lock it after failed attempts, and require an authenticated, explicit human approval — never auto-grant from a pre-filled link. Store the device_code and issued tokens as hashes, make the device_code strictly single-use by deleting it on success, and bind the granted token to the exact scopes the user consented to. Run every leg over TLS, and show the user the real scope list on the approval page so consent means something. Get those right and you have a sign-in that takes a viewer about ten seconds with a phone they're already holding.
Conclusion
The device authorization grant is one of those protocols that looks trivial in the RFC and turns into a checklist of small, sharp edges in production: code entropy, hashed secrets, single-use enforcement, server-driven back-off, and cache layers that must be told to keep their hands off. None of it is hard individually. The win is real, though — on constrained Asia-Pacific TV hardware, moving authentication off the remote and onto the phone is the difference between a login people complete and one they abandon. Build the four messages carefully, enforce slow_down on the server, keep Cloudflare and LiteSpeed away from the token endpoint, and the rest is just polling.
Top comments (0)