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

  1. 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).
  2. The device displays: "Go to dfg.com/device and enter BDWD-HQPK".
  3. The user opens that URL on their phone or laptop, logs in normally, and enters the code.
  4. Meanwhile the device polls /token with the device code.
  5. Polling returns authorization_pending until the user finishes, then returns real tokens.

Polling rules that matter

  • Respect the interval the 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_denied and expired_token distinctly — 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

Example · bash
# 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":"...", ...}         ← done

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

Example · javascript
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.

Example · javascript
// 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

  • Be the first to comment on this lesson.