Skip to content

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.

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.

  1. The device calls startDevicePairing() and displays two things: a user code (XXXX-XXXX) and a verification code.

  2. 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.

  3. 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.

  4. The browser seals the master seed to the device’s public key and posts the ciphertext. The device’s next poll returns it.

  5. 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.

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:

FieldTypeWhat
userCodestringThe typed code, formatted "XXXX-XXXX". Display it.
verificationCodestring8 characters the user compares across screens. Display it.
verificationUristringWhere to go — auth.muhkoo.dev/link.
verificationUriCompletestringSame URI with the code pre-filled. Render as a QR code.
expiresAtnumberAbsolute epoch-ms. The code lives 10 minutes.
intervalnumberServer-recommended seconds between polls (starts at 5).
deviceCodestringOpaque. Pass it back in; never render it.
privateKeyCryptoKeyThe attempt’s ephemeral private half. In memory only.
devicePublicKeyB64stringIts 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"

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 verifies

resumeDeviceSession() 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 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”.

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.

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";
reasonWhat happenedWhat the device should do
deniedThe user tapped Decline.Say so, offer a new code.
expiredThe code ran out (10 min), or was already used.Get a new code automatically.
abortedcancelDevicePairing or an aborted signal.Nothing — you asked for this.
rate_limitedToo many attempts. .retryAfter holds seconds.Wait, then restart.
networkRepeated transport failures — the loop already retried with backoff.”Check the connection”, offer retry.
commitment_mismatchThe sealed seed doesn’t derive the commitment the server claims.Fail loudly. Do not sign in; tell the user to report it.
account_mismatchThis device was previously paired to a different account.Refuse. Require an explicit “forget this device” (forgetDeviceSession()) first.
reauth_requiredApprover’s sign-in is too old for a new device.Approver-side: prompt for a factor, retry.
invalid_user_codeThe typed code isn’t a valid code.Approver-side: re-prompt.
errorEverything 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.

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.

MethodPathWho
POST/api/auth/device/codeDevice — start a pairing (unauthenticated).
POST/api/auth/device/tokenDevice — poll; the one and only time the sealed envelope is returned.
POST/api/auth/device/lookupApprover — resolve a typed code (session-authed).
POST/api/auth/device/approveApprover — post the sealed envelope (session-authed).
POST/api/auth/device/denyApprover — refuse (session-authed).
GET / PUT / DELETE/api/auth/devicesList, 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.