Signing in a VR headset the way your TV does
Bannermarch got VR support this week. You open the game in the Meta Quest browser, hit the button, and you're standing inside your holding. Before any of that, though, you have to sign in. On a Quest that means pointing a laser at a floating keyboard and pecking out your email one letter at a time, then your password, which is probably sitting in a password manager on your phone. So you take the headset off to go look. (I wrote about how the VR itself runs in the browser in a separate post.)
TVs figured this out years ago. The YouTube app on a TV shows you a code, you go to a URL on your phone, type it in, and the TV signs itself in. Netflix does it, the PlayStation does it. There's even an RFC for it (RFC 8628, the OAuth device authorization grant). I built the same thing for the game. The headset shows a code, and you approve it at /pair on a phone or laptop where you're already signed in.
I'll walk through how it works, because it's a nice small piece of system design and most people have used it without thinking about what's behind it.
Who talks to who
The headset and your phone never talk to each other. I didn't do anything clever with Bluetooth or the local network. Both of them talk to the game's server, and the server holds on to a pending code for ten minutes. You're the only link between the two screens. You read eight characters off one and type them into the other.
Headset Server Phone
| | |
|-- start ----------------->| |
|<-- code K7MP2QXR ---------| |
| + secret in a cookie | |
| | |
|-- poll (still waiting) -->|<-- open /pair, type code -|
| |--- "Meta Quest, Miami" -->|
| |<-- approve ---------------|
|-- poll + secret --------->| |
|<-- signed in -------------| |
Here's the order things happen in.
The headset asks the server to start a pairing. The server generates a short code for you to read, like K7MP2QXR. It also generates a long random secret, 32 bytes, and sends it back to the headset in a cookie. The server stores a hash of that secret, not the secret.
The headset puts the code on screen and starts asking the server every 2.5 seconds if someone approved it. There's a QR code on screen too, which points at /pair#K7MP2QXR. That works great on a TV or a laptop. On a headset it's pretty useless, but I'll get to that.
You open /pair on your phone and type the code. The server looks it up and shows you what device is asking and roughly where it is. It guesses "Meta Quest" from the browser's User-Agent and gets a city and country from the IP. You tap approve. The server writes your account ID next to the pending code.
The headset's next poll sends the cookie. The server hashes the secret, compares it, sees the approval, deletes the pending entry and gives the headset its own session. You're in the game.
Why a stolen code is useless
The code sits on a screen, so I assume other people can read it. Your brother on the couch can. So can anyone watching a stream.
That's the reason for the secret. When you approve, your account doesn't go to whoever knows the code. It goes to the one browser holding the secret, and that browser is the headset. The cookie is HttpOnly, so JavaScript on the page can't read it, and SameSite=Strict, so another site can't get the browser to send it. If somebody copies the code off your TV, they can't collect anything with it.
The session can be collected once. The server deletes the pending entry the moment the headset picks it up, and any later poll gets "expired". If nobody approves the code in ten minutes, it's gone anyway.
I didn't worry much about guessing. The codes use 31 characters (I dropped 0, O, 1, I and L because people mix them up), and eight of those gives about 850 billion combinations. Approving is rate limited on top of that, and each code only lives ten minutes.
Phishing
The secret doesn't help if you approve the wrong code. Say someone starts a pairing on their own laptop and messages you "enter this code to confirm your account". You approve it, and now their laptop has your session. It's called device code phishing. The RFC warns about it, and attackers have used it against Microsoft 365 accounts.
I can't fully stop that, but I can make it obvious. The approval screen names the device and the place it's connecting from, with a warning to only approve a code you can see on a device you're holding. If you live in Miami and it says "Windows PC" in some country you've never been to, you'll probably notice.
Headsets aren't TVs
About twenty minutes after I pushed the first version, I pushed a second one. With a TV you can look at the code and your phone at the same time. With a headset on you can't see your phone at all, so you end up lifting the headset to read the code, typing it, and putting it back on. The QR code doesn't work either, because your phone's camera can't see inside the lenses.
So now it also goes the other way. On /pair there's a "Get a code for your headset" button. Your phone asks the server for a code tied to your account, you read it, put the headset on and type it into the sign-in screen. Eight characters with the laser keyboard is still annoying. It's a lot less annoying than an email and a password.
This version is less safe than the first one. There's no secret behind the code, so whoever types it first gets in. I kept it anyway and fenced it in. The code only gets issued to a signed-in account with a verified email. It works once and lasts ten minutes, and typing codes is rate limited.
I also keep the two kinds of codes in separate tables. If the "type a code" box accepted a headset's code, then right after you approved one, anybody who'd seen it on your TV could type it somewhere else and get your session. With separate tables a headset's code just fails there, approved or not.
How it's built
That's the idea. If you want to build your own, here's what's actually in the code. The server is a Cloudflare Worker in TypeScript. Only the storage part is Cloudflare-specific, and I'll say what to swap it for.
Six endpoints
| Endpoint | Called by | What it does |
|---|---|---|
POST /api/auth/device/start | Headset | Makes a code and a secret, sets the cookie |
POST /api/auth/device/poll | Headset | Answers pending, expired, or signs the headset in |
POST /api/auth/device/lookup | Signed-in phone | Shows which device and place is behind a code |
POST /api/auth/device/approve | Signed-in phone | Attaches your account to the code |
POST /api/auth/device/issue | Signed-in phone | Makes a code for the reverse flow |
POST /api/auth/device/redeem | Headset | Trades a typed code for a session |
Every one of them is a POST and every response has Cache-Control: no-store, so nothing with a code or a session in it gets cached along the way. Lookup and approve are split on purpose. The phone calls lookup first to show you the device name and place, and only calls approve after you've read it and tapped the button. Anything a signed-in phone calls also checks for a verified email and goes through a per-account rate limit.
Making the code
The code comes from crypto.getRandomValues, never Math.random. There's one small trap. A random byte goes from 0 to 255, and if you just take byte % 31, the first few letters of the alphabet come up slightly more often than the rest, because 256 doesn't divide evenly by 31. The fix is to throw away any byte of 248 or higher and pull another. That's called rejection sampling, and it's three extra lines.
const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // 31 symbols, no 0 O 1 I L
function newPairingCode(): string {
// 256 isn't a multiple of 31, so drop the top bytes to keep every symbol equally likely
const limit = 256 - (256 % alphabet.length); // 248
let code = "";
while (code.length < 8) {
for (const byte of crypto.getRandomValues(new Uint8Array(16))) {
if (byte < limit && code.length < 8) code += alphabet[byte % alphabet.length];
}
}
return code;
}
// People type "k7mp-2qxr" or "K7MP 2QXR". Accept both.
function normalizeCode(input: string): string | null {
const code = input.toUpperCase().replace(/[\s-]/g, "");
return code.length === 8 && [...code].every((c) => alphabet.includes(c)) ? code : null;
}
The server also forgives how people type. Lowercase, spaces and dashes all get cleaned up before the code is checked, so nobody gets an error for typing it the way it looks.
The secret lives in the cookie
Here's the start handler, trimmed a little.
async function startPairing(request: Request, env: Env): Promise<Response> {
const secret = base64Url(crypto.getRandomValues(new Uint8Array(32)));
const secretHash = await sha256Hex(secret);
for (let attempt = 0; attempt < 4; attempt += 1) {
const code = newPairingCode();
const expiresAt = await env.DEVICE_PAIRING
.getByName(`device-pairing:${code}`)
.begin(secretHash, deviceLabel(request), devicePlace(request));
if (expiresAt === null) continue; // code already taken, roll another one
const headers = new Headers({ "Cache-Control": "no-store" });
headers.append("Set-Cookie",
`__Host-bm_device_pairing=${code}.${secret}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=600`);
return Response.json({ code, expiresAt }, { headers });
}
return Response.json({ error: "Try again." }, { status: 503 });
}
A few things are going on. The server only ever stores secretHash. The raw secret goes out once, in the cookie, so even someone reading the database can't pretend to be the headset. The cookie carries both the code and the secret (K7MP2QXR.abc...), which means the poll request doesn't need a body at all. The browser sends the cookie and the server knows which code to check and can prove it's the right headset.
The __Host- prefix tells the browser this cookie only works over HTTPS on this exact domain, with no subdomain able to set or overwrite it. And there's the retry loop. Two headsets could randomly get the same code, and when that happens begin returns null and the handler rolls a new one. With 850 billion possible codes it basically never loops, but it has to be handled.
One row, a few states
Each pending code is a single row.
CREATE TABLE pairing (
id INTEGER PRIMARY KEY CHECK (id = 1), -- one row per object, ever
secret_hash TEXT NOT NULL, -- sha256 of the headset's secret
device_label TEXT NOT NULL, -- "Meta Quest"
place TEXT, -- "Miami, US"
expires_at INTEGER NOT NULL,
account_key TEXT -- NULL until someone approves
);
The state of a pairing comes straight from that row. If account_key is empty, it's pending. Once someone approves, it's filled in. When the headset collects, the row is deleted. If expires_at has passed, the code counts as expired whatever else the row says. There's no status column to keep in sync, which is one less way for it to go wrong.
This is what the headset's poll ends up calling.
claim(secretHash: string) {
const row = this.pairing(); // null if missing or past expires_at
if (!row || row.secret_hash !== secretHash) return { status: "expired" };
if (!row.account_key) return { status: "pending" };
this.ctx.storage.sql.exec("DELETE FROM pairing"); // collect once
return { status: "approved", accountKey: row.account_key };
}
Look at the first check. A wrong secret gets the same "expired" answer as a missing code. The server never tells a stranger "that code exists, but you're not the right device". On a successful collect the row is deleted before the session goes out, so the same approval can't be used twice.
The headset's side
The client is a small loop.
const poll = () => {
timer = setTimeout(async () => {
try {
const result = await api.pollDevicePairing();
if (result.authenticated) onSignedIn(result);
else if (result.status === "expired") showExpired();
else poll(); // still pending, ask again in 2.5s
} catch {
poll(); // flaky headset Wi-Fi shouldn't kill the pairing
}
}, 2_500);
};
It uses setTimeout that reschedules itself instead of setInterval. If one request is slow, the next one waits for it rather than piling up behind it. Errors keep the loop going, because headset Wi-Fi drops for a second now and then and that shouldn't cost you the code. When the poll finally succeeds, the response carries the normal session cookie, same as a password login, and the game loads.
Where the codes are stored
The game runs on Cloudflare Workers. Every code gets its own Durable Object, named after the code, holding a tiny SQLite table with one row.
I went with that because of the collect-once rule. A Durable Object handles its requests one at a time, so two polls for the same code can't both grab the approval. Each one also sets an alarm for a minute after the code expires, and the alarm deletes its storage. I don't need a cleanup job.
If you're not on Cloudflare, Redis does the same job. Store each code as a key with a ten minute TTL (SET ... EX 600 NX, where NX gives you the "code already taken" check for free). For the collect step, check the secret, check the approval and delete the key inside one Lua script so it all happens atomically. A plain Postgres table works too if you do the claim as a single DELETE ... WHERE ... RETURNING and run a cleanup query now and then.
The QR code puts the code after a # in the URL. Browsers don't send that part to the server, so codes never show up in access logs. And the headset polls instead of keeping a WebSocket open. Worst case that's 240 small requests over ten minutes, which is nothing, and I didn't want to debug WebSockets in the Quest browser.