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
| Step | Method & path | Body | Returns |
|---|---|---|---|
| Start | POST /api/device/start | { device_secret_hash, client_id?, scope? } | { user_code, device_code, verification_uri, verification_uri_complete, expires_in, interval } |
| Poll | POST /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_iddefaults to"default"andscopeto"openid profile email". Pass your registeredclientIdso the tokenaudis meaningful.- The issued
access_tokenis the same EdDSA JWT as every other flow — verify it exactly as described in Verify JWT.
See Also
- Sign In via CLI Server — local-server callback flow
- Verify JWT — validate and parse the token