Sign In via Device Flow

Authenticate from a headless or browserless environment — an SSH session, a container, CI, or a device without its own browser — using the OAuth 2.0 Device Authorization Grant (RFC 8628).


Overview

The device shows the user a short code and a URL. The user opens that URL on any other device that has a browser, enters the code, and approves. Meanwhile the device polls until a token is issued.

device  ──POST /api/device/start──▶  { user_code, verification_uri, ... }
  │   user opens verification_uri on a phone/laptop, enters user_code, approves
device  ──POST /api/device/poll──▶   202 authorization_pending ... then 200 { access_token }

Note: See Verify JWT for how to validate the issued token, and Token & JWKS Reference for its shape.


Endpoints

StepMethod & pathBodyReturns
StartPOST /api/device/start{ device_secret_hash, client_id?, scope? }{ user_code, device_code, verification_uri, verification_uri_complete, expires_in, interval }
PollPOST /api/device/poll{ device_secret_hash, client_id? }202 { error: "authorization_pending" }200 { access_token, token_type }

The web page the user lands on drives the approval itself; your device only calls start and poll.

The device_secret_hash

Your device invents a random secret and sends its hash on start, then polls with the same hash. This ties your poll requests to the session you started.


Implementation (Node, zero dependencies)

import crypto from "node:crypto";

const BASE = "https://staging-auth.vuer.ai"; // or https://auth.vuer.ai
const secret = crypto.randomBytes(32).toString("hex");
const deviceSecretHash = crypto.createHash("sha256").update(secret).digest("hex");

// 1. Start the device flow
const start = await (
  await fetch(`${BASE}/api/device/start`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ device_secret_hash: deviceSecretHash, scope: "openid profile email" }),
  })
).json();

console.log(`\nOn another device, open:\n  ${start.verification_uri_complete}`);
console.log(`Or visit ${start.verification_uri} and enter code: ${start.user_code}\n`);

// 2. Poll until the user approves
const interval = (start.interval ?? 5) * 1000;
const deadline = Date.now() + (start.expires_in ?? 600) * 1000;

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

  const res = await fetch(`${BASE}/api/device/poll`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ device_secret_hash: deviceSecretHash }),
  });

  if (res.status === 202) {
    process.stdout.write("."); // authorization_pending — keep waiting
    continue;
  }

  if (res.ok) {
    const { access_token } = await res.json();
    console.log("\nAuthorized. Token acquired.");
    // e.g. fs.writeFileSync(".vuer_token", access_token)
    break;
  }

  const err = await res.json().catch(() => ({}));
  console.error("\nDevice flow failed:", err.error ?? res.status);
  break;
}

Notes

  • Codes expire in about 10 minutes (expires_in: 600). The default poll interval is 5 seconds — respect it to avoid rate limiting.
  • client_id defaults to "default" and scope to "openid profile email". Pass your registered clientId so the token aud is meaningful.
  • The issued access_token is the same EdDSA JWT as every other flow — verify it exactly as described in Verify JWT.

See Also