DEV Community

ahmet gedik
ahmet gedik

Posted on

Building an OAuth2 Device Authorization Flow for Smart TV Apps in PHP

A user opens our app on a Samsung TV, lands on the login screen, and is handed an on-screen keyboard they have to navigate with a four-way D-pad. To type m0nitor@rakort.com plus a 16-character password, they press the remote roughly 90 times, mistype twice, and give up. That is the real problem behind smart TV authentication: the device has a screen but no usable input. Passwords, OAuth redirect flows, and anything involving a browser popup are all hostile on a 10-foot interface.

At TrendVidStream we run streaming-platform discovery across eight regions, and our TV apps need to link a viewer's account so their watchlist and regional catalog follow them from phone to living room. The answer is RFC 8628, the OAuth2 Device Authorization Grant. The TV shows a short code and a URL; the user pulls out the phone that is already in their hand and finishes the login there. This post is the actual implementation we ship: the two server endpoints in PHP 8.4 backed by SQLite, the polling client in Go, and the operational details (rate limiting, code hygiene, multi-region cron) that only show up once real devices hit it.

How the device authorization grant actually works

The flow has four moving parts and one counterintuitive property: the device that wants the token never talks to a browser. Here is the sequence:

  • The TV app calls the device authorization endpoint. It gets back a device_code (secret, for the TV), a user_code (short, human-readable), a verification_uri, an interval, and an expiry.
  • The TV displays the user_code and verification_uri on screen. Optionally a QR code encoding verification_uri_complete.
  • The user opens the URL on a phone or laptop, authenticates normally (password manager, passkey, existing session — all the things that are easy on a real keyboard), and approves the device.
  • Meanwhile the TV polls the token endpoint with its device_code. It receives authorization_pending until the user approves, then finally the access and refresh tokens.

The elegance is that the hard part of authentication — typing credentials, solving MFA, reading consent screens — moves to a device that is good at it. The TV only ever polls. Nothing sensitive is ever entered on the remote.

A few constants worth fixing up front, because they shape the schema:

  • device_code: 32+ bytes of entropy, never shown to a human.
  • user_code: short, uppercase, ambiguity-free alphabet, shown big on the TV.
  • expires_in: typically 600–900 seconds. Long enough to walk to your phone, short enough to limit the attack window.
  • interval: minimum seconds between polls (we use 5).

The device authorization endpoint

This is the endpoint the TV hits first. It generates both codes, stores a pending record, and returns the payload. We use SQLite because our whole stack is file-based and FTP-deployed — there is no separate database server to provision per region, the .db file ships with the app. Here is the table and the endpoint in PHP 8.4:

<?php
declare(strict_types=1);

// schema.sql (run once at migration time)
// CREATE TABLE device_auth (
//   device_code   TEXT PRIMARY KEY,
//   user_code     TEXT NOT NULL UNIQUE,
//   client_id     TEXT NOT NULL,
//   scope         TEXT NOT NULL DEFAULT '',
//   status        TEXT NOT NULL DEFAULT 'pending', -- pending|approved|denied
//   user_id       INTEGER,
//   last_polled   INTEGER NOT NULL DEFAULT 0,
//   interval_s    INTEGER NOT NULL DEFAULT 5,
//   created_at    INTEGER NOT NULL,
//   expires_at    INTEGER NOT NULL
// );
// CREATE INDEX idx_device_auth_expiry ON device_auth(expires_at);

final class DeviceAuthController
{
    private const LIFETIME = 600;      // seconds
    private const INTERVAL = 5;        // seconds between polls
    // No 0/O/1/I/L to avoid remote-typing and read-aloud confusion
    private const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';

    public function __construct(private readonly PDO $db) {}

    public function authorize(array $post): array
    {
        $clientId = $post['client_id'] ?? '';
        if ($clientId === '' || !$this->isKnownClient($clientId)) {
            return ['status' => 400, 'body' => ['error' => 'invalid_client']];
        }
        $scope = trim((string)($post['scope'] ?? 'catalog watchlist'));

        $deviceCode = bin2hex(random_bytes(32));
        $userCode   = $this->makeUserCode();
        $now        = time();

        $stmt = $this->db->prepare(
            'INSERT INTO device_auth
             (device_code, user_code, client_id, scope, status,
              interval_s, created_at, expires_at)
             VALUES (?,?,?,?,?,?,?,?)'
        );
        $stmt->execute([
            $deviceCode, $userCode, $clientId, $scope, 'pending',
            self::INTERVAL, $now, $now + self::LIFETIME,
        ]);

        $base = 'https://trendvidstream.com';
        return ['status' => 200, 'body' => [
            'device_code'              => $deviceCode,
            'user_code'               => $userCode,
            'verification_uri'        => $base . '/link',
            'verification_uri_complete' => $base . '/link?code=' . $userCode,
            'expires_in'              => self::LIFETIME,
            'interval'                => self::INTERVAL,
        ]];
    }

    private function makeUserCode(): string
    {
        // 8 chars, grouped as XXXX-XXXX for on-screen display
        for ($attempt = 0; $attempt < 5; $attempt++) {
            $code = '';
            for ($i = 0; $i < 8; $i++) {
                $code .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)];
            }
            $check = $this->db->prepare('SELECT 1 FROM device_auth WHERE user_code = ?');
            $check->execute([$code]);
            if ($check->fetchColumn() === false) {
                return $code;
            }
        }
        throw new RuntimeException('user_code collision, retry exhausted');
    }

    private function isKnownClient(string $clientId): bool
    {
        $stmt = $this->db->prepare('SELECT 1 FROM oauth_clients WHERE client_id = ?');
        $stmt->execute([$clientId]);
        return $stmt->fetchColumn() !== false;
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things I want to defend here. First, the user_code collision loop is real code, not paranoia. With 30 characters and 8 positions you have about 6.5 x 10^11 combinations, but you only care about collisions among currently pending codes. At our volume that is a handful at a time, so retries essentially never fire — but a UNIQUE constraint plus a retry loop is the difference between a correct system and one that occasionally hands two TVs the same code. Second, the separation between device_code (32 bytes, secret) and user_code (8 chars, public) is the whole security model. The short code is guessable by design, so it must never be sufficient to obtain a token on its own.

Designing a user code that survives a TV remote

The alphabet choice above is not cosmetic. On a 10-foot screen read from the couch, 0 and O, 1 and I and L, and S and 5 are indistinguishable, and the verification page has to accept whatever the user thinks they saw. Our approach:

  • Restricted alphabet. Drop 0 O 1 I L. We keep S and 5 but you can drop those too if your font is ambiguous.
  • Uppercase only, normalize on input. When the phone submits the code, uppercase it and strip the hyphen before lookup. The user should never fail because they typed lowercase or forgot the dash.
  • Grouping. Display PQ7M-KX92, store PQ7MKX92. Groups of four are far easier to hold in short-term memory while you walk to your phone.
  • QR fallback. We render verification_uri_complete as a QR code next to the text. A phone camera skips the typing entirely, which for our non-technical viewers is the path most of them actually take.

On the verification page, normalization looks like this:

$input = strtoupper(preg_replace('/[^A-Z0-9]/i', '', $_POST['user_code'] ?? ''));
// PQ7M-KX92, pq7mkx92, 'PQ7M KX92' all collapse to PQ7MKX92
Enter fullscreen mode Exit fullscreen mode

The token endpoint and the polling contract

This is where most implementations get subtle bugs. The TV polls this endpoint on a loop, and RFC 8628 defines a precise set of responses. Getting the state machine wrong means either the TV hammers your server or it never notices the user approved.

<?php
declare(strict_types=1);

final class DeviceTokenController
{
    public function __construct(
        private readonly PDO $db,
        private readonly TokenIssuer $issuer,
    ) {}

    public function token(array $post): array
    {
        $grant = $post['grant_type'] ?? '';
        if ($grant !== 'urn:ietf:params:oauth:grant-type:device_code') {
            return $this->err(400, 'unsupported_grant_type');
        }
        $deviceCode = $post['device_code'] ?? '';
        if ($deviceCode === '') {
            return $this->err(400, 'invalid_request');
        }

        $row = $this->fetch($deviceCode);
        if ($row === null) {
            return $this->err(400, 'invalid_grant'); // unknown or already consumed
        }

        $now = time();
        if ($now >= (int)$row['expires_at']) {
            $this->db->prepare('DELETE FROM device_auth WHERE device_code = ?')
                     ->execute([$deviceCode]);
            return $this->err(400, 'expired_token');
        }

        // Enforce the minimum poll interval. Too-fast polling => slow_down.
        $minGap = (int)$row['interval_s'];
        if ($now - (int)$row['last_polled'] < $minGap) {
            $this->bump($deviceCode, $now);
            return $this->err(400, 'slow_down');
        }
        $this->bump($deviceCode, $now);

        return match ($row['status']) {
            'pending'  => $this->err(400, 'authorization_pending'),
            'denied'   => $this->err(400, 'access_denied'),
            'approved' => $this->grantTokens($row),
            default    => $this->err(400, 'invalid_grant'),
        };
    }

    private function grantTokens(array $row): array
    {
        // Single-use: consume the device_code so a replay can't mint a 2nd token.
        $del = $this->db->prepare(
            'DELETE FROM device_auth WHERE device_code = ? AND status = ?'
        );
        $del->execute([$row['device_code'], 'approved']);
        if ($del->rowCount() === 0) {
            return $this->err(400, 'invalid_grant'); // lost the race, already consumed
        }

        $tokens = $this->issuer->issue(
            userId: (int)$row['user_id'],
            clientId: $row['client_id'],
            scope: $row['scope'],
        );
        return ['status' => 200, 'body' => $tokens];
    }

    private function fetch(string $deviceCode): ?array
    {
        $stmt = $this->db->prepare('SELECT * FROM device_auth WHERE device_code = ?');
        $stmt->execute([$deviceCode]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row === false ? null : $row;
    }

    private function bump(string $deviceCode, int $now): void
    {
        $this->db->prepare('UPDATE device_auth SET last_polled = ? WHERE device_code = ?')
                 ->execute([$now, $deviceCode]);
    }

    private function err(int $status, string $code): array
    {
        return ['status' => $status, 'body' => ['error' => $code]];
    }
}
Enter fullscreen mode Exit fullscreen mode

The response codes are not interchangeable, and the client behavior differs for each:

  • authorization_pending — keep polling at the current interval. Normal state.
  • slow_down — you polled too fast; the client MUST increase its interval by at least 5 seconds. We return this whenever two polls arrive inside the window.
  • access_denied — the user rejected the device. Stop polling, show a friendly "link canceled" screen.
  • expired_token — the code timed out. Stop, restart the flow with a fresh code.
  • invalid_grant — unknown, already used, or malformed. Stop.

The single-use consumption in grantTokens() uses a conditional DELETE ... WHERE status = 'approved' and checks rowCount(). That is the concurrency guard: if two polls arrive at nearly the same instant after approval, only one DELETE affects a row, and only that one mints tokens. SQLite serializes writes, so this is safe without an explicit transaction, but if you move to a busier store, wrap fetch-check-consume in a transaction with the right isolation.

The TV client polling loop

Smart TV apps are increasingly written in whatever the platform allows, but the polling logic is universal. Here it is in Go, which is what our reference device-linking test harness uses. Note how it honors both the server-provided interval and the slow_down back-pressure:

package devicelink

import (
    "context"
    "encoding/json"
    "net/http"
    "net/url"
    "strings"
    "time"
)

type tokenResp struct {
    Error        string `json:"error"`
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
}

// Poll blocks until the user approves, denies, or the code expires.
func Poll(ctx context.Context, base, deviceCode string, interval time.Duration) (*tokenResp, error) {
    if interval < 5*time.Second {
        interval = 5 * time.Second
    }
    form := url.Values{
        "grant_type":  {"urn:ietf:params:oauth:grant-type:device_code"},
        "device_code": {deviceCode},
    }

    for {
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(interval):
        }

        resp, err := http.Post(base+"/oauth/token",
            "application/x-www-form-urlencoded",
            strings.NewReader(form.Encode()))
        if err != nil {
            continue // transient network blip on the TV; retry next tick
        }
        var tr tokenResp
        _ = json.NewDecoder(resp.Body).Decode(&tr)
        resp.Body.Close()

        switch {
        case tr.AccessToken != "":
            return &tr, nil
        case tr.Error == "authorization_pending":
            // keep waiting at current interval
        case tr.Error == "slow_down":
            interval += 5 * time.Second
        case tr.Error == "access_denied",
            tr.Error == "expired_token",
            tr.Error == "invalid_grant":
            return &tr, nil // terminal; caller inspects tr.Error
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The important discipline is that the client treats slow_down as an instruction, not an error to log and ignore. A TV app that polls a fixed 1-second loop against a fleet of a few hundred thousand living rooms is a self-inflicted DDoS. The interval belongs to the server, and the client obeys it.

Rate limiting, expiry, and multi-region hygiene

The device flow has two abuse surfaces that a naive implementation leaves open.

The first is user_code brute force on the verification page. Because the user code is short and public, an attacker who can guess an active code before the legitimate user approves could hijack a pending link. The mitigations: keep the lifetime short (600s), enforce a strict per-IP rate limit on the /link code-submission form, and — critically — bind approval to a real authenticated session on the phone. Guessing the code is not enough; you also have to be logged in and click approve.

The second is polling storms and stale rows. Every abandoned link (user starts on the TV, gets distracted, never finishes) leaves a pending row. Across eight regions that accumulates. We already run a multi-region cron for catalog refresh, so cleanup rides along as one more step. Here is the sweep, in Python, matching the cron style we use elsewhere:

#!/usr/bin/env python3
"""Purge expired device-auth rows. Runs every 10 min per region."""
import sqlite3, time, sys

def sweep(db_path: str) -> int:
    now = int(time.time())
    con = sqlite3.connect(db_path, timeout=30)
    try:
        con.execute("PRAGMA busy_timeout = 5000")
        cur = con.execute(
            "DELETE FROM device_auth WHERE expires_at < ?", (now,)
        )
        con.commit()
        return cur.rowcount
    finally:
        con.execute("PRAGMA optimize")
        con.close()

if __name__ == "__main__":
    for region_db in sys.argv[1:]:
        removed = sweep(region_db)
        print(f"{region_db}: purged {removed} expired device_auth rows")
Enter fullscreen mode Exit fullscreen mode

Because each region carries its own SQLite file and the whole app is FTP-deployed, there is no shared session store to keep consistent — a device linked in the EU region polls the EU token endpoint, and its row lives in that region's .db. That is a deliberate simplification: the device flow is naturally region-local since the same TV always talks to the same edge. The only cross-region concern is that the resulting refresh token, once issued, has to be honored globally, which is a property of your token issuer, not the device flow.

Security details people get wrong

A short checklist from things I have actually seen break:

  • Never return a token on a device_code you did not consume. The conditional-delete-then-issue pattern above is non-negotiable. A replay of the same device_code must fail after the first success.
  • The user_code must be single-use and pending-scoped. Once approved or expired, it must not be re-approvable.
  • Bind scope at authorization time, not token time. The TV requests scope up front; the user approves that exact scope on the phone. Do not let the token endpoint widen it.
  • Rate-limit both endpoints independently. The device endpoint (code creation) and the token endpoint (polling) have different abuse profiles.
  • Show the user what they are approving. The verification page must display the client name and requested scopes. "A TV wants access to your watchlist" is the whole point of consent.
  • Use random_int / random_bytes, never mt_rand. These codes are security tokens.

Conclusion

The device authorization grant is one of those specs that looks trivial and turns out to be exactly as detailed as it needs to be. The core idea — move authentication off the input-hostile device onto the phone already in the user's hand — is worth internalizing beyond TVs; it applies to CLI tools, IoT, and anything without a keyboard. The parts that bite are the polling state machine (authorization_pending vs slow_down vs access_denied are not interchangeable), single-use consumption under concurrent polls, and code hygiene so a short public code cannot be brute-forced before approval.

What surprised me shipping this on a file-based PHP 8.4 and SQLite stack is how little infrastructure it actually needs: two endpoints, one table, a cleanup cron, and disciplined clients. No message queue, no session cluster, no separate auth service. If you get the state machine and the single-use guarantee right, the rest is just remembering that the person on the couch is holding the best input device they own — you just have to hand them a short code and get out of the way.

Top comments (0)