Device Authorization Flow
Logging in on a TV, a CLI or anything else without a usable browser or keyboard.
Some clients cannot run the normal flow: a smart TV, a games console, a CLI on a remote server, an IoT panel. Either there is no browser, or typing a password with a remote control is unbearable. The device authorization flow (RFC 8628) moves the interactive part to a device the user already has.
The flow
- The device asks the authorization server for a code pair: a device_code (long, for the device) and a user_code (short, for the human).
- The device displays: "Go to
dfg.com/deviceand enterBDWD-HQPK". - The user opens that URL on their phone or laptop, logs in normally, and enters the code.
- Meanwhile the device polls
/tokenwith the device code. - Polling returns
authorization_pendinguntil the user finishes, then returns real tokens.
Polling rules that matter
- Respect the
intervalthe server returned — typically 5 seconds. - On
slow_down, increase your interval permanently. Servers will reject a client that ignores it. - Stop at
expires_in(usually 10–15 minutes) and show a fresh code. - Handle
access_deniedandexpired_tokendistinctly — the user needs different messages.
Designing the user code
It is typed by a human, possibly with a remote control. Use an alphabet without 0/O and 1/I/L, group it with a dash, and keep it to 8 characters. Rate-limit verification attempts: a short code is guessable if you allow unlimited tries.
The confirmation screen matters
Before approving, show what is being authorized: the client name and the scopes. Device flow phishing works by getting a victim to enter a code the attacker generated — the confirmation screen is the user's only chance to notice they are authorizing something they did not start.
Example
# 1. Device asks for codes
curl -X POST https://auth.dfg.com/oauth/device/code \
-d client_id=tv-app -d scope='openid profile'
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "BDWD-HQPK",
"verification_uri": "https://dfg.com/device",
"verification_uri_complete": "https://dfg.com/device?user_code=BDWD-HQPK",
"expires_in": 900,
"interval": 5
}
# 2. TV shows: "Visit dfg.com/device and enter BDWD-HQPK"
# (and a QR code for verification_uri_complete)
# 3. Device polls every 5 seconds
curl -X POST https://auth.dfg.com/oauth/token \
-d grant_type=urn:ietf:params:oauth:grant-type:device_code \
-d device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS \
-d client_id=tv-app
# {"error":"authorization_pending"} ← keep polling
# {"error":"slow_down"} ← increase the interval
# {"access_token":"...", ...} ← doneWhen to use it
- A CLI tool runs 'login' on a headless server and prints a code the engineer enters on their laptop, so no password is ever typed into the terminal.
- A smart TV app signs a user in by showing a QR code that opens the pre-filled verification URL on their phone.
- An IoT installer provisions a wall panel by approving its device code from a phone, with the panel never handling credentials.
More examples
A polling client that behaves
slow_down increases the interval permanently rather than for one iteration — resetting it afterwards is the mistake that gets clients rate-limited.
const AUTH = 'https://auth.dfg.com';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function deviceLogin(clientId, scope) {
const start = await (await fetch(`${AUTH}/oauth/device/code`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ client_id: clientId, scope }),
})).json();
console.log(`\n Go to ${start.verification_uri}`);
console.log(` and enter the code: ${start.user_code}\n`);
let interval = (start.interval ?? 5) * 1000;
const deadline = Date.now() + start.expires_in * 1000;
while (Date.now() < deadline) {
await sleep(interval);
const res = await fetch(`${AUTH}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: start.device_code,
client_id: clientId,
}),
});
const body = await res.json();
if (res.ok) return body; // tokens
switch (body.error) {
case 'authorization_pending': break; // normal: keep waiting
case 'slow_down': interval += 5000; break; // permanent increase
case 'access_denied': throw new Error('The request was denied.');
case 'expired_token': throw new Error('The code expired. Start again.');
default: throw new Error(body.error_description || body.error);
}
}
throw new Error('The code expired. Start again.');
}User codes people can actually type
Normalising the input — uppercasing and stripping the dash — means a user who types 'bdwdhqpk' still succeeds, which removes most support tickets this flow generates.
// Alphabet with no visually confusable characters: no 0/O, no 1/I/L, no 5/S.
const ALPHABET = 'BCDFGHJKMNPQRSTVWXZ23456789';
export function generateUserCode() {
const bytes = crypto.getRandomValues(new Uint8Array(8));
const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]);
return `${chars.slice(0, 4).join('')}-${chars.slice(4).join('')}`; // BDWD-HQPK
}
// 27^8 ≈ 2.8e11 combinations — plenty, but ONLY if guessing is rate-limited.
export async function verifyUserCode(req, res) {
const ip = req.ip;
const attempts = await redis.incr(`device-verify:${ip}`);
await redis.expire(`device-verify:${ip}`, 900);
if (attempts > 10) return res.status(429).json({ error: 'too_many_attempts' });
const code = String(req.body.user_code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
const pending = await db.deviceCodes.findByUserCode(code);
if (!pending || pending.expiresAt < new Date()) {
return res.status(400).json({ error: 'invalid_or_expired_code' });
}
// Show WHAT is being approved before accepting the approval.
res.json({
clientName: pending.client.name,
scopes: pending.scope.split(' '),
requestedAt: pending.createdAt,
});
}
Discussion