OAuth device flow: authenticating TVs, CLIs, and IoT without a browser

The standard OAuth 2.0 authorization code flow assumes the device initiating the flow can open a browser and handle a redirect. For command-line tools, smart TVs, game consoles, and headless IoT devices, this assumption doesn't hold. RFC 8628 — the OAuth 2.0 Device Authorization Grant — solves exactly this problem. It's the flow behind "Visit example.com/activate and enter code WXYZ-PQRS" on your TV screen, and it powers gh auth login and similar CLI authentication flows.

How the device flow works

The flow has two parallel legs:

Device leg: The constrained device registers with the auth server, receives a device code and user code, displays the user code, and polls the token endpoint until authorization completes or the codes expire.

User leg: The user visits a "device activation" URL on any capable device (phone, laptop), enters the user code, authenticates, and approves the authorization.

The two legs complete asynchronously. The device is polling; the user is authenticating independently.

Authorization server implementation

import { randomBytes } from 'crypto';

// Step 1: Device requests authorization
router.post('/oauth/device/code', async (req, res) => {
  const { client_id, scope } = req.body;

  // Validate client
  const client = await db.oauthClients.findById(client_id);
  if (!client || !client.deviceFlowEnabled) {
    return res.status(400).json({ error: 'unauthorized_client' });
  }

  // Generate device_code (high entropy, not user-visible)
  const deviceCode = randomBytes(32).toString('base64url');
  // Generate user_code (human-typeable: 8 chars, unambiguous alphabet)
  const userCode = generateUserCode();

  const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes

  await db.deviceAuthorizations.create({
    deviceCode,
    userCode,
    clientId: client_id,
    scope,
    expiresAt,
    status: 'pending',
    interval: 5, // seconds between polls
  });

  res.json({
    device_code: deviceCode,
    user_code: userCode,
    verification_uri: 'https://app.bastionary.com/device',
    verification_uri_complete: `https://app.bastionary.com/device?user_code=${userCode}`,
    expires_in: 900,
    interval: 5,
  });
});

// User code format: 8 chars from an unambiguous alphabet, split into two groups
function generateUserCode(): string {
  // Avoid ambiguous chars: 0/O, 1/I/L
  const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
  let code = '';
  const bytes = randomBytes(8);
  for (const byte of bytes) {
    code += ALPHABET[byte % ALPHABET.length];
  }
  return `${code.slice(0, 4)}-${code.slice(4)}`; // WXYZ-PQRS format
}

The polling endpoint

// Step 2: Device polls this endpoint
router.post('/oauth/token', async (req, res) => {
  const { grant_type, device_code, client_id } = req.body;
  if (grant_type !== 'urn:ietf:params:oauth:grant-type:device_code') return next();

  const auth = await db.deviceAuthorizations.findByDeviceCode(device_code);

  if (!auth || auth.clientId !== client_id) {
    return res.status(400).json({ error: 'invalid_grant' });
  }

  if (new Date() > auth.expiresAt) {
    return res.status(400).json({ error: 'expired_token' });
  }

  // Enforce polling interval to prevent hammering
  const now = Date.now();
  const lastPoll = auth.lastPolledAt?.getTime() || 0;
  if (now - lastPoll < (auth.interval * 1000) - 500) { // 500ms tolerance
    await db.deviceAuthorizations.update(auth.id, {
      lastPolledAt: new Date(),
      interval: auth.interval + 5, // slow down on too-fast polling
    });
    return res.status(400).json({ error: 'slow_down' });
  }

  await db.deviceAuthorizations.update(auth.id, { lastPolledAt: new Date() });

  switch (auth.status) {
    case 'pending':
      return res.status(400).json({ error: 'authorization_pending' });

    case 'denied':
      return res.status(400).json({ error: 'access_denied' });

    case 'approved': {
      // Issue tokens
      const tokens = await issueTokensForUser(auth.userId!, auth.scope, auth.clientId);
      await db.deviceAuthorizations.update(auth.id, { status: 'consumed' });
      return res.json({
        access_token: tokens.accessToken,
        token_type: 'Bearer',
        expires_in: 900,
        refresh_token: tokens.refreshToken,
        scope: auth.scope,
      });
    }

    default:
      return res.status(400).json({ error: 'invalid_grant' });
  }
});

The user activation page

// User visits /device, enters or sees pre-filled user_code, and approves
router.get('/device', (req, res) => {
  res.render('device-activate', {
    prefilledCode: req.query.user_code || '',
  });
});

router.post('/device/activate', requireAuth, async (req, res) => {
  const { user_code } = req.body;
  const normalized = user_code.toUpperCase().replace(/[^A-Z0-9]/g, '');

  const auth = await db.deviceAuthorizations.findByUserCode(
    `${normalized.slice(0, 4)}-${normalized.slice(4)}`
  );

  if (!auth || auth.status !== 'pending' || new Date() > auth.expiresAt) {
    return res.render('device-activate', {
      error: 'Invalid or expired code. Please try again.',
    });
  }

  // Show consent screen with requested scopes
  const client = await db.oauthClients.findById(auth.clientId);
  res.render('device-consent', {
    auth,
    client,
    scopes: auth.scope.split(' '),
  });
});

router.post('/device/approve', requireAuth, async (req, res) => {
  const { auth_id, action } = req.body;
  const auth = await db.deviceAuthorizations.findById(auth_id);

  await db.deviceAuthorizations.update(auth.id, {
    status: action === 'approve' ? 'approved' : 'denied',
    userId: req.user.id,
  });

  res.render('device-complete', {
    approved: action === 'approve',
    clientName: auth.clientName,
  });
});

CLI integration (client side)

// bastionary-cli login command
async function deviceLogin(baseUrl: string): Promise<void> {
  // Step 1: Request device code
  const codeResponse = await fetch(`${baseUrl}/oauth/device/code`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: 'bastionary-cli',
      scope: 'read:profile write:profile',
    }),
  }).then(r => r.json());

  console.log(`\nTo sign in, visit: ${codeResponse.verification_uri}`);
  console.log(`Enter code: ${codeResponse.user_code}`);
  console.log(`\nWaiting for authorization...`);

  // Optionally open browser automatically
  // await open(codeResponse.verification_uri_complete);

  // Step 2: Poll for completion
  const deadline = Date.now() + codeResponse.expires_in * 1000;
  let interval = codeResponse.interval * 1000;

  while (Date.now() < deadline) {
    await new Promise(r => setTimeout(r, interval));

    const tokenResponse = await fetch(`${baseUrl}/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: codeResponse.device_code,
        client_id: 'bastionary-cli',
      }),
    });

    const data = await tokenResponse.json();

    if (tokenResponse.ok) {
      await storeTokens(data); // save to ~/.config/bastionary/credentials
      console.log('Logged in successfully.');
      return;
    }

    if (data.error === 'slow_down') interval += 5000;
    else if (data.error === 'authorization_pending') continue;
    else if (data.error === 'access_denied') {
      console.error('Authorization denied.');
      process.exit(1);
    } else if (data.error === 'expired_token') {
      console.error('Code expired. Run login again.');
      process.exit(1);
    }
  }

  console.error('Timed out waiting for authorization.');
  process.exit(1);
}
For TV/streaming device UX, display a QR code alongside the user code. Use a QR code library to encode the verification_uri_complete URL — most phones can scan it directly to jump to the activation page without typing the URL. This cuts activation time from ~30 seconds to ~5 seconds.