TV & device pairing
A TV app can’t run the redirect flow: there’s no keyboard to type a password, no WebAuthn in an Android TV WebView, and no redirect URL for the browser to come back to. Device pairing solves that — the device displays a short code, the user approves it from a browser where they’re already signed in, and the account’s master seed is delivered sealed to that device alone.
The seed is the point. Muhkoo storage and messaging are end-to-end encrypted, so a session token by itself gets you a signed-in TV that can decrypt nothing. Once paired, the device holds the same identity as every other device on the account and plays the user’s media directly.
How it works
Section titled “How it works”This is the existing sealed-seed handoff with one substitution. In the redirect flow the seal key rides the URL fragment, which never reaches a server. A TV has no redirect to receive, so the seal key is derived by ECDH against an ephemeral key the device generates, and the redirect becomes a poll. The accelerator still only ever relays a ciphertext it cannot open.
-
The device calls
startDevicePairing()and displays two things: a user code (XXXX-XXXX) and a verification code. -
The user opens the approval screen on a phone or laptop — the device shows the URL (
session.verificationUri,auth.muhkoo.dev/link) and a QR code that pre-fills the user code — signs in, and enters the code. -
The approval screen shows the device’s label and its own copy of the verification code. The user compares it with the TV screen and taps Approve.
-
The browser seals the master seed to the device’s public key and posts the ciphertext. The device’s next poll returns it.
-
The device unseals the seed, checks that it derives the commitment the server claims, mints its own long-lived session, and persists the identity so it comes back signed in after a restart.
The device side
Section titled “The device side”Everything lives on client.auth.hosted. Start a pairing, render the codes, and
await the result — waitForDevicePairing runs the whole poll loop, including the
server’s slow_down backoff and expiry, and resolves with the signed-in user.
import { DevicePairingError } from "@muhkoo/connect";
const session = await client.auth.hosted.startDevicePairing({ appId: "<your-app-id>", label: "Living room TV", // optional; defaults to a user-agent guess});
renderPairingScreen({ userCode: session.userCode, // "K7QD-3MXR" — show this large verificationCode: session.verificationCode, // 8 chars — the one the user compares qr: session.verificationUriComplete, // auth.muhkoo.dev/link?code=… expiresAt: session.expiresAt, // epoch ms — 10 minutes});
try { const user = await client.auth.hosted.waitForDevicePairing(session, { onTick: (remainingMs) => showCountdown(remainingMs), }); // { username, commitment } — session + identity established. // Storage, messaging, and decryption work from here exactly as on any device. startApp(user);} catch (err) { if (err instanceof DevicePairingError) showPairingError(err.reason, err.message);}startDevicePairing returns a DevicePairingSession:
| Field | Type | What |
|---|---|---|
userCode | string | The typed code, formatted "XXXX-XXXX". Display it. |
verificationCode | string | 8 characters the user compares across screens. Display it. |
verificationUri | string | Where to go — auth.muhkoo.dev/link. |
verificationUriComplete | string | Same URI with the code pre-filled. Render as a QR code. |
expiresAt | number | Absolute epoch-ms. The code lives 10 minutes. |
interval | number | Server-recommended seconds between polls (starts at 5). |
deviceCode | string | Opaque. Pass it back in; never render it. |
privateKey | CryptoKey | The attempt’s ephemeral private half. In memory only. |
devicePublicKeyB64 | string | Its public half, base64 raw SEC1. |
To cancel — the user backed out, or you’re tearing down the screen — call
cancelDevicePairing(session). It’s synchronous, and any in-flight
waitForDevicePairing rejects with reason aborted on its next tick. Drop your
reference to the session object afterwards: that’s what makes the ephemeral key
unrecoverable.
// Retire the session — any later poll on it fails too:client.auth.hosted.cancelDevicePairing(session);
// Or drive the wait with a signal, if that fits your component lifecycle:const controller = new AbortController();client.auth.hosted.waitForDevicePairing(session, { signal: controller.signal });controller.abort(); // → rejects with reason "aborted"Staying signed in across restarts
Section titled “Staying signed in across restarts”A paired device persists its identity, so a reboot doesn’t mean re-pairing. Check for it on boot and resume:
if (await client.auth.hosted.hasDeviceSession()) { const user = await client.auth.hosted.resumeDeviceSession(); if (user) return startApp(user); // signed in, identity unlocked}showPairingScreen(); // nothing stored, or it no longer verifiesresumeDeviceSession() returns null — never throws — when there’s nothing
stored, when the stored blob doesn’t derive its own pinned commitment (corrupt or
tampered storage), or when the account no longer accepts the identity. Treat
null as “start a fresh pairing”.
forgetDeviceSession() is the sign-out: it wipes the persisted seed, the device
identity key, and the session.
The approver side
Section titled “The approver side”The approval screen is a browser your user is already signed in on. All three calls need a signed-in session with the seed unlocked, exactly like the hosted redirect flow — the approver’s browser is what does the sealing, and the server never holds a key that opens it.
import { DevicePairingError, ReauthRequiredError } from "@muhkoo/connect";
// 1. Resolve the code the user typed.const req = await client.auth.hosted.lookupDevicePairing(typedCode);
// 2. Show it, and get an explicit tap. Never auto-approve.// req.verificationCode is recomputed locally from req.devicePublicKeyB64 —// the server's copy is discarded.showApprovalPrompt({ label: req.deviceLabel, // "Living room TV" app: req.appLabel, code: req.verificationCode, // must match the TV screen known: req.isKnownDevice, // has this device paired before? reauth: req.requiresReauth, // will approval need a fresh factor?});
// 3. On tap:try { const { deviceId } = await client.auth.hosted.approveDevicePairing(req, { label: userRenamedIt, // optional — let the user rename the device });} catch (err) { if (err instanceof ReauthRequiredError) { await promptForFactor(); // password / passkey / Google await client.auth.hosted.approveDevicePairing(req); // then retry } else if (err instanceof DevicePairingError) { showError(err.message); }}To refuse, call denyDevicePairing(userCode). It needs no fresh factor and no
consent gate — declining must never be harder than accepting — and it leaves the
device a clear “declined” rather than an indistinguishable “expired”.
Listing and revoking devices
Section titled “Listing and revoking devices”Every account can see its paired devices and remove them. Render this wherever your users manage their account — a “Signed-in devices” list beside their other security settings:
const devices = await client.auth.hosted.listPairedDevices();// [{ id, label, appId, pairedAt, lastSeenAt, activeSession }, …]
await client.auth.hosted.revokePairedDevice(devices[0].id);An account can pair at most 20 devices; approving beyond that fails with
.code === "too_many_devices" until one is removed.
Handling errors
Section titled “Handling errors”Both errors come from the package root. DevicePairingError.reason is the field
a UI state machine should branch on; .code carries the server’s machine code
verbatim for the cases reason collapses into "error".
import { DevicePairingError, ReauthRequiredError } from "@muhkoo/connect";reason | What happened | What the device should do |
|---|---|---|
denied | The user tapped Decline. | Say so, offer a new code. |
expired | The code ran out (10 min), or was already used. | Get a new code automatically. |
aborted | cancelDevicePairing or an aborted signal. | Nothing — you asked for this. |
rate_limited | Too many attempts. .retryAfter holds seconds. | Wait, then restart. |
network | Repeated transport failures — the loop already retried with backoff. | ”Check the connection”, offer retry. |
commitment_mismatch | The sealed seed doesn’t derive the commitment the server claims. | Fail loudly. Do not sign in; tell the user to report it. |
account_mismatch | This device was previously paired to a different account. | Refuse. Require an explicit “forget this device” (forgetDeviceSession()) first. |
reauth_required | Approver’s sign-in is too old for a new device. | Approver-side: prompt for a factor, retry. |
invalid_user_code | The typed code isn’t a valid code. | Approver-side: re-prompt. |
error | Everything else — read .code. | Show .message. |
commitment_mismatch and account_mismatch are the two that deserve real
screens rather than a toast. They’re the checks that turn a substituted seed from
silent into loud, so a device that hits them must refuse to sign in rather than
degrade.
Notable .code values arriving as reason: "error": too_many_devices (the
20-device cap), key_mismatch (the approval was computed for a different device
— start again), consent_required (the account must accept updated terms;
.details.version says which), and unknown_app.
ReauthRequiredError extends DevicePairingError (so an instanceof DevicePairingError catch still sees it — check for it first) and adds
authAgeSeconds and maxAgeSeconds.
Under the hood
Section titled “Under the hood”The device half of the flow is unauthenticated by construction — the device has no credentials yet. What stands in for authentication is a persistent device identity key (ECDSA P-256, private half non-extractable) that signs a possession proof on every pairing start. That’s what makes “known device” a proven key rather than a string anyone could claim to skip the approver’s re-authentication.
| Method | Path | Who |
|---|---|---|
| POST | /api/auth/device/code | Device — start a pairing (unauthenticated). |
| POST | /api/auth/device/token | Device — poll; the one and only time the sealed envelope is returned. |
| POST | /api/auth/device/lookup | Approver — resolve a typed code (session-authed). |
| POST | /api/auth/device/approve | Approver — post the sealed envelope (session-authed). |
| POST | /api/auth/device/deny | Approver — refuse (session-authed). |
| GET / PUT / DELETE | /api/auth/devices | List, check in, revoke (session-authed). |
Codes are 8 characters from a 30-symbol alphabet with every confusable pair
removed in both directions — no O/0, no I/1/L, and no U (read as V
on the low-resolution type a TV renders across a room). A typed O is therefore
a genuine misread, and the SDK tells the user rather than silently guessing 0.
See the client.auth reference for the rest of
client.auth.hosted, and the authentication guide for
the redirect flow this builds on.