The Tizen build of our TV app shipped with an email-and-password form. Median time to complete a login was 74 seconds. 61% of sessions that opened the form never finished it. The support inbox told us why: on a Samsung remote you drag a D-pad cursor across a 40-key on-screen grid to find the @ symbol, and on the Japanese and Korean builds that grid opens in Kana or Hangul mode by default, so the first six characters of a password come out as ひらがな and the user starts over from scratch.
We deleted the form and implemented RFC 8628, the OAuth 2.0 Device Authorization Grant. The TV displays an eight-character code and a short URL, the user finishes on a phone, and the TV polls until a token appears. Median link time dropped to 19 seconds and completion went to 88%. What follows is the server side of that implementation — PHP 8.4 on LiteSpeed, SQLite for grant state, Cloudflare in front — including the parts the RFC does not cover, like full-width Unicode arriving from CJK input methods and a page cache that will cheerfully serve a stale authorization_pending to a TV that was authorized ten seconds ago.
What the device grant actually requires
The device grant is not "OAuth without a browser." It is OAuth where the browser lives on a different device. The TV never sees a credential, which is the security win: a leaked TV app binary contains no password-handling code path at all.
Four moving parts:
-
Device authorization endpoint. The TV POSTs
client_idandscope, gets backdevice_code,user_code,verification_uri,verification_uri_complete,expires_in, andinterval. -
The TV screen. Shows the
user_codeand theverification_uri, plus a QR ofverification_uri_completeso most users never type anything. - The verification page. An ordinary authenticated web page where the user confirms the code and approves the scope.
-
Token endpoint polling. The TV POSTs
grant_type=urn:ietf:params:oauth:grant-type:device_codeon a fixed interval and getsauthorization_pending,slow_down,access_denied, orexpired_tokenuntil it gets tokens.
The error codes are the protocol. Every one of them is a 400 with a JSON body, and a client that treats a 400 as fatal will break on the very first poll — a mistake I have now made in two separate codebases.
State lives in one SQLite table
A device grant is short-lived, single-tenant-ish state with a very high read-to-write ratio during its 15-minute life. One table is enough. We run SQLite 3.45 in WAL mode with a 5-second busy timeout, and the whole grant lifecycle never touches another table until the moment tokens are issued.
CREATE TABLE device_auth (
id INTEGER PRIMARY KEY,
device_code_hash BLOB NOT NULL UNIQUE, -- sha256(device_code), raw 32 bytes
user_code TEXT NOT NULL UNIQUE, -- canonical, no separator
client_id TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','approved','denied','redeemed')),
user_id INTEGER,
device_label TEXT NOT NULL DEFAULT '', -- 'Samsung QN90C · Tokyo, JP'
interval_s INTEGER NOT NULL DEFAULT 5,
poll_count INTEGER NOT NULL DEFAULT 0,
last_polled_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
) STRICT;
CREATE INDEX idx_device_auth_sweep ON device_auth(expires_at);
Three decisions worth defending:
-
STRICTtables (SQLite 3.37+) mean a stray string inexpires_atfails at insert time instead of silently comparing wrong three weeks later. There is no reason not to use them on new tables. -
device_codeis hashed at rest;user_codeis not. The device code is a pure bearer credential — anyone holding it can redeem tokens the moment the grant is approved, so a database leak must not hand it over. The user code is worthless on its own: redeeming it requires an authenticated session on the verification page, and it dies in 15 minutes. Keeping it in plaintext lets support answer "the TV shows K7M2-BXRP, what happened?" without a lookup table, and lets us index it directly. -
No separate
poll_logtable.poll_counton the row gives us everything we need for telemetry, and it is a same-row update we were already doing.
Issuing the code
<?php
declare(strict_types=1);
final readonly class DeviceCodeGrant
{
// No vowels (avoids accidental words in any language), no 0/O/Q/1/I/L
// (unreadable at three metres on a 4K panel with a CJK-first font stack).
private const string ALPHABET = '23456789BCDFGHJKMNPRSTVWXYZ';
private const int LIFETIME = 900; // 15 minutes
private const int INTERVAL = 5; // seconds between polls
public function __construct(private PDO $db) {}
public function authorize(string $clientId, string $scope, string $label): array
{
$deviceCode = bin2hex(random_bytes(32));
$userCode = $this->mintUserCode();
$now = time();
$st = $this->db->prepare(
'INSERT INTO device_auth
(device_code_hash, user_code, client_id, scope, device_label,
interval_s, created_at, expires_at)
VALUES (:h, :u, :c, :s, :l, :i, :now, :exp)'
);
$st->bindValue(':h', hash('sha256', $deviceCode, true), PDO::PARAM_LOB);
$st->bindValue(':u', $userCode);
$st->bindValue(':c', $clientId);
$st->bindValue(':s', $scope);
$st->bindValue(':l', $label);
$st->bindValue(':i', self::INTERVAL, PDO::PARAM_INT);
$st->bindValue(':now', $now, PDO::PARAM_INT);
$st->bindValue(':exp', $now + self::LIFETIME, PDO::PARAM_INT);
$st->execute();
return [
'device_code' => $deviceCode,
'user_code' => $this->pretty($userCode), // K7M2-BXRP
'verification_uri' => 'https://topvideohub.com/link',
'verification_uri_complete' => 'https://topvideohub.com/link?code=' . $userCode,
'expires_in' => self::LIFETIME,
'interval' => self::INTERVAL,
];
}
private function mintUserCode(): string
{
$max = strlen(self::ALPHABET) - 1;
for ($attempt = 0; $attempt < 5; $attempt++) {
$code = '';
for ($i = 0; $i < 8; $i++) {
$code .= self::ALPHABET[random_int(0, $max)];
}
$st = $this->db->prepare(
'SELECT 1 FROM device_auth WHERE user_code = ? AND expires_at > ?'
);
$st->execute([$code, time()]);
if ($st->fetchColumn() === false) {
return $code;
}
}
throw new RuntimeException('user_code collision budget exhausted');
}
private function pretty(string $c): string
{
return substr($c, 0, 4) . '-' . substr($c, 4);
}
}
random_int(), not mt_rand(). The user code is a guessable-space credential and the device code is a bearer token; both need the CSPRNG. The retry loop exists because the UNIQUE constraint will otherwise throw a raw PDOException at an unlucky user, and five attempts against a live set of a few thousand codes in a 2.8 × 10^11 space is a formality that costs nothing.
Choosing an alphabet that survives a three-metre viewing distance
This is the part where the RFC says "the user code SHOULD be short" and leaves you alone with the consequences.
Our alphabet is 27 characters: digits 2–9 and consonants excluding I, L, O, Q. Eight characters gives 27^8 ≈ 2.8 × 10^11 possible codes. Excluding vowels means the generator cannot produce a word — in English, or, more to the point, in romanised Japanese or Indonesian, where a four-consonant-plus-vowel run has a much better chance of landing on something a support agent has to apologise for. Excluding 0/O/Q and 1/I/L removes the two confusion classes that actually cause failed entries.
We considered a digits-only code, which is what several large TV platforms use, because a numeric code triggers a numeric keypad on the phone and is unambiguous in every font on earth. We rejected it: nine digits is a 10^9 space, and defending 10^9 against distributed guessing means aggressive global rate limiting on the verification page — a control that fails open under load in exactly the situation where you need it. We would rather spend the entropy and lean on the QR code so that typing is the fallback path, not the main one.
One rendering detail specific to our market: our TV UI ships a CJK-first font stack so Japanese and Korean titles render without fallback. That stack will happily draw Latin glyphs in a half-width CJK face, where 2 and Z are nearly identical at distance. The link screen forces a Latin display font and tabular figures for the code element only. That single CSS change moved our "code not recognised" rate more than any server-side work did.
Normalizing what the phone actually sends
Here is the bug that cost us a day. A user in Osaka opens the link page on a phone with the Japanese IME active, types the code, and the field receives K7M2ーBXRP — full-width Latin letters (U+FF21–U+FF3A), full-width digits (U+FF10–U+FF19), and a katakana prolonged sound mark instead of a hyphen. Byte-compared against K7M2BXRP, none of it matches. Our logs showed a burst of failed lookups clustered in JP and KR traffic and nowhere else.
NFKC normalization folds every one of those back to ASCII:
final class UserCode
{
private const string ALPHABET = '23456789BCDFGHJKMNPRSTVWXYZ';
/** @return array{0: ?string, 1: ?string} [canonical code, failure hint] */
public static function normalize(string $raw): array
{
// Full-width Latin/digits from a Japanese or Korean IME, ideographic
// space U+3000, full-width hyphen U+FF0D -> all fold to ASCII here.
$s = class_exists(\Normalizer::class)
? (\Normalizer::normalize($raw, \Normalizer::FORM_KC) ?: $raw)
: $raw;
$s = mb_strtoupper(trim($s), 'UTF-8');
$s = preg_replace('/[^A-Z0-9]/u', '', $s) ?? ''; // drops -, spaces, ー, ・
if (strlen($s) !== 8) {
return [null, 'length'];
}
if (strspn($s, self::ALPHABET) !== 8) {
$bad = preg_replace('/[' . self::ALPHABET . ']/', '', $s) ?? '';
return [null, self::hint($bad)];
}
return [$s, null];
}
private static function hint(string $bad): string
{
return match (true) {
str_contains($bad, '0'),
str_contains($bad, 'O'),
str_contains($bad, 'Q') => 'no_o_zero',
str_contains($bad, '1'),
str_contains($bad, 'I'),
str_contains($bad, 'L') => 'no_i_one',
default => 'charset',
};
}
}
Because the alphabet deliberately excludes those confusable characters, an O in the input is always a transcription error, never a valid code. That lets the page say something precise — "codes never contain the letter O, try zero-free" localised per market — instead of a generic "invalid code." We log the hint bucket, not the code itself; the distribution is how we learned that no_i_one was three times more common than no_o_zero on our Traditional Chinese build.
Don't assume ext-intl is present. On one of our LiteSpeed hosts it was not compiled in, and the class_exists guard is what kept the endpoint from fataling instead of merely degrading.
Approval, and the phishing problem the RFC hands to you
RFC 8628 §5.4 is explicit that this grant is phishable: an attacker starts a device authorization for their own client, sends the victim a link with verification_uri_complete, and if the victim clicks approve, the attacker's device gets a token for the victim's account. The protocol cannot prevent this. Your verification page has to.
What we do on the confirm screen:
-
Never approve on GET.
verification_uri_completepre-fills the code and nothing else. Approval is a POST with a per-session CSRF token. -
Show what is being approved, in the user's language. Client display name, the device label we captured at authorization time (model plus city derived from
CF-IPCountryand the Cloudflare colo), and the plain-language scope list. "A Samsung TV in Tokyo wants access to your watchlist" is a sentence a user can evaluate; "Approve scope profile watchlist" is not. - Require a live session. If the user is not logged in, they authenticate first and land back on the confirm screen with the code preserved.
- Cap attempts. Five wrong user codes per session and per IP per fifteen minutes, then the page stops accepting entries. Against 2.8 × 10^11 this is generous, but it is what turns the entropy argument into an actual guarantee.
-
Write denials. A "No, this wasn't me" button sets
status='denied'so the TV receivesaccess_deniedon its next poll and clears the screen immediately, instead of showing a code that silently rots for fifteen minutes.
The token endpoint and the polling state machine
The polling endpoint has one race worth caring about: two concurrent polls with the same device code must not both redeem an approved grant. SQLite gives you this for free with BEGIN IMMEDIATE, which takes the write lock at statement one rather than upgrading later and losing to SQLITE_BUSY.
The subtler trap is that the interval-enforcement write has to survive the error path. If you throw slow_down from inside the transaction and roll back, you discard the last_polled_at update that makes rate limiting work — so an abusive client gets slow_down forever and is never actually slowed. Compute the outcome inside the transaction, commit, then map the outcome to a response.
public function poll(string $clientId, string $deviceCode): array
{
$hash = hash('sha256', $deviceCode, true);
$now = time();
$this->db->exec('BEGIN IMMEDIATE'); // PDO::beginTransaction() is DEFERRED on SQLite
try {
$st = $this->db->prepare(
'SELECT id, client_id, status, user_id, scope, interval_s, last_polled_at, expires_at
FROM device_auth WHERE device_code_hash = :h'
);
$st->bindValue(':h', $hash, PDO::PARAM_LOB);
$st->execute();
$outcome = $this->advance($st->fetch(PDO::FETCH_ASSOC), $clientId, $now);
} catch (Throwable $e) {
$this->db->exec('ROLLBACK');
throw $e;
}
$this->db->exec('COMMIT');
return match ($outcome['state']) {
'issued' => $outcome['tokens'],
'pending' => throw new OAuthError('authorization_pending'),
'slow' => throw new OAuthError('slow_down'),
'denied' => throw new OAuthError('access_denied'),
'expired' => throw new OAuthError('expired_token'),
default => throw new OAuthError('invalid_grant'),
};
}
private function advance(array|false $row, string $clientId, int $now): array
{
// Unknown code and wrong client are indistinguishable to the caller, on purpose.
if ($row === false || !hash_equals((string) $row['client_id'], $clientId)) {
return ['state' => 'invalid'];
}
if ($now >= $row['expires_at'] || $row['status'] === 'redeemed') {
return ['state' => 'expired'];
}
if ($now - $row['last_polled_at'] < $row['interval_s']) {
// RFC 8628 §3.5: the client MUST add 5s to its interval on slow_down.
$this->run(
'UPDATE device_auth
SET interval_s = MIN(interval_s + 5, 60),
poll_count = poll_count + 1,
last_polled_at = ?
WHERE id = ?',
[$now, $row['id']]
);
return ['state' => 'slow'];
}
$this->run(
'UPDATE device_auth SET last_polled_at = ?, poll_count = poll_count + 1 WHERE id = ?',
[$now, $row['id']]
);
return match ($row['status']) {
'pending' => ['state' => 'pending'],
'denied' => ['state' => 'denied'],
'approved' => ['state' => 'issued', 'tokens' => $this->issueTokens($row)],
default => ['state' => 'invalid'],
};
}
issueTokens() runs inside the same transaction and sets status='redeemed' before returning, so a duplicate poll arriving a millisecond later blocks on the write lock and then reads redeemed and gets expired_token. The interval is capped at 60 seconds so a badly written client cannot back itself off into an unusable state. Every response carries Cache-Control: no-store and HTTP 400 for the error cases — a 401 or 403 will send well-behaved OAuth clients down a token-refresh path that does not exist here.
LiteSpeed, Cloudflare, and the cached authorization_pending
Our stack has three caching layers in front of PHP: LiteSpeed's page cache, our own file-based page cache in index.php, and Cloudflare. During the first staging run, a TV would get approved on the phone and then poll authorization_pending for the rest of the grant's life. The approval was in the database. The endpoint was returning a cached response from our own PHP layer, which keyed on the request URI and had never been taught that POST bodies exist.
The checklist we ended up with:
-
Skip the PHP page cache by path prefix. Anything under
/oauth/bypasses both the read and the write side of the file cache, before any other logic runs. -
CacheDisable public /oauthinside the<IfModule LiteSpeed>block in.htaccess. Keep it inside the guard — Apache parses that block's directives differently and you will get a 500 in any environment that isn't LiteSpeed. -
Cache-Control: no-storeon every response, success and error alike. This is also what stops an intermediate proxy on a hotel or carrier network from holding a token response. -
Rate limit on
CF-Connecting-IP, notREMOTE_ADDR. Behind Cloudflare,REMOTE_ADDRis a Cloudflare edge address, so an IP-keyed limiter buckets tens of thousands of unrelated users together. Validate that the request actually came from a Cloudflare range before trusting the header, or you have just built a header-spoofable bypass. -
A Cloudflare rate-limiting rule as the outer wall, roughly 30 requests per minute per IP on
/oauth/token. The in-app limiter still exists; the edge rule is what keeps a misbehaving firmware build from reaching PHP at all.
A cron sweep deletes expired rows hourly and runs PRAGMA optimize. Without it the table grows monotonically with dead grants and the expires_at index gradually stops being worth reading.
The TV client
Our shipping client is JavaScript on Tizen and C++ elsewhere, but the state machine is identical everywhere and this is the reference implementation we test the server against:
import random
import time
import requests
DEVICE_URL = 'https://api.topvideohub.com/oauth/device/code'
TOKEN_URL = 'https://api.topvideohub.com/oauth/token'
CLIENT_ID = 'tv-tizen-2026'
GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'
def link_device(render_on_tv):
r = requests.post(DEVICE_URL, timeout=10, data={
'client_id': CLIENT_ID,
'scope': 'profile watchlist',
})
r.raise_for_status()
auth = r.json()
render_on_tv(auth['user_code'], auth['verification_uri'],
qr=auth['verification_uri_complete'])
interval = auth.get('interval', 5)
deadline = time.monotonic() + auth['expires_in']
while time.monotonic() < deadline:
# Jitter matters: after a regional outage every TV in the fleet
# reconnects at once and polls in lockstep without it.
time.sleep(interval + random.uniform(0, 1.0))
try:
resp = requests.post(TOKEN_URL, timeout=10, data={
'client_id': CLIENT_ID,
'device_code': auth['device_code'],
'grant_type': GRANT_TYPE,
})
except requests.RequestException:
continue # a network blip is not a slow_down; don't back off
if resp.status_code == 200:
return resp.json()
error = resp.json().get('error')
if error == 'authorization_pending':
continue
if error == 'slow_down':
interval = min(interval + 5, 60)
continue
raise RuntimeError('device link failed: ' + str(error))
raise TimeoutError('user code expired before approval')
Things the client must get right, all of which we got wrong at least once:
-
expires_inis authoritative. Stop polling and re-render a fresh code; do not poll a dead grant forever. - Use a monotonic clock. TVs correct their system clock over NTP shortly after boot, which is exactly when this loop is running, and a wall-clock deadline can jump backwards by hours.
- Treat transport failures separately from protocol errors. Backing off on a dropped TCP connection means a user on flaky hotel Wi-Fi waits a minute for a screen that should have advanced in five seconds.
- Render the QR from
verification_uri_complete. Around 70% of our successful links now come from a QR scan, and those sessions never touch the normalization code path at all.
Operational notes after a few months
poll_count turned out to be the most useful column in the table. The distribution tells you the real approval latency without any client instrumentation: our p50 is four polls, p90 is eleven, and a bimodal second hump around fifty polls was how we discovered that a Korean carrier's captive portal was intercepting the verification URL and users were retrying on a second device.
Fifteen minutes is a deliberate lifetime choice. Five minutes is too tight for a user who has to go find their phone, unlock it, and log in on a page they have never seen; an hour leaves a valid credential on screen after the family has gone to bed. Fifteen covers the observed p99 with margin.
We also removed the password field from the TV binary entirely rather than leaving it as a fallback. A fallback path that 3% of users take is a path you stop testing, and it was the only place in the app that ever handled a credential. Deleting it took a whole class of problems off the board — which, along with the abandon-rate numbers, is why the device grant is now the only way to sign in to TopVideoHub on a television.
Conclusion
RFC 8628 is a short spec and the happy path is a weekend of work. The cost is entirely in the edges: an alphabet that survives a living-room viewing distance, NFKC normalization for input methods that emit full-width Latin text, interval enforcement that persists through the error path, single-use redemption under a real write lock, and cache layers that must be told in three separate places not to touch these endpoints. None of that is in the RFC, and all of it is what separates a device flow that demos well from one that works for a user in Osaka on a Samsung remote at eleven at night.
Top comments (0)