kavachOS
Authentication

Device authorization

OAuth 2.0 device flow for CLIs, smart TVs, and input-constrained devices. RFC 8628.

The device authorization grant (RFC 8628) lets a device that cannot show a browser — a CLI tool, smart TV, game console, or IoT sensor — authenticate by delegating the sign-in step to a secondary device the user already trusts.

The device displays a short code like BDFK-RSTV. The user opens a URL on their phone or laptop, signs in, types the code, and the waiting device gets a session. No credentials ever travel through the constrained device.

Setup

Add the plugin

lib/kavach.ts
import { createKavach } from 'kavachos';
import { deviceAuth } from 'kavachos/auth'; 

const kavach = await createKavach({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.KAVACH_SECRET!,
  baseUrl: 'https://auth.example.com',
  plugins: [
    deviceAuth({ 
      verificationUri: 'https://app.example.com/device', 
    }), 
  ],
});

Build the user-facing approval page

Create a page at your verificationUri. It should let a signed-in user enter the code the device is showing and approve or deny the request.

app/device/page.tsx (Next.js)
export default function DevicePage({
  searchParams,
}: {
  searchParams: { user_code?: string };
}) {
  // user_code is pre-filled when the device uses verification_uri_complete
  const [code, setCode] = useState(searchParams.user_code ?? '');

  async function approve() {
    await fetch('/auth/device/authorize', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ user_code: code, action: 'approve' }), 
    });
  }

  async function deny() {
    await fetch('/auth/device/authorize', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ user_code: code, action: 'deny' }), 
    });
  }

  return (
    <form>
      <input value={code} onChange={e => setCode(e.target.value)} placeholder="XXXX-XXXX" />
      <button type="button" onClick={approve}>Approve</button>
      <button type="button" onClick={deny}>Deny</button>
    </form>
  );
}

The /auth/device/authorize endpoint requires an active session. The user must be signed in before they can approve or deny a device code.

Device flow

Request codes (on the device)

POST /auth/device/code

The device calls this endpoint to start a new authorization attempt. No authentication required.

CLI tool
const res = await fetch('https://auth.example.com/auth/device/code', {
  method: 'POST',
});

const data = await res.json();
// {
//   device_code: 'a3f9c2e1d8b04f7a...',  // opaque, used for polling
//   user_code: 'BDFK-RSTV',              // shown to the user
//   verification_uri: 'https://app.example.com/device',
//   verification_uri_complete: 'https://app.example.com/device?user_code=BDFK-RSTV',
//   expires_in: 900,                     // seconds until the code expires
//   interval: 5,                         // minimum seconds between poll requests
// }

console.log(`Open ${data.verification_uri} and enter: ${data.user_code}`);

Display user_code prominently — this is what the user types. verification_uri_complete includes the code as a query parameter, so you can also show a QR code for it.

Poll for authorization (on the device)

POST /auth/device/token

Poll this endpoint at the interval returned in the previous step (default: every 5 seconds). Keep polling until you get an authorized response or the code expires.

CLI tool
const { device_code, interval, expires_in } = data;
const deadline = Date.now() + expires_in * 1000;

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

  const pollRes = await fetch('https://auth.example.com/auth/device/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ device_code }), 
  });

  const result = await pollRes.json();

  if (pollRes.ok && result.authorized) {
    console.log('Authorized! User ID:', result.user_id); 
    break;
  }

  if (result.error === 'slow_down') { 
    // Server asked for a longer interval
    interval = result.interval; 
    continue;
  }

  if (result.error === 'access_denied') {
    console.error('User denied the request.');
    break;
  }

  if (result.error === 'expired_token') {
    console.error('Code expired — start over.');
    break;
  }

  // 'authorization_pending' — keep polling
}

User approves (on the secondary device)

The user opens verification_uri on their phone or laptop, signs in, and enters the code. The approval page calls /auth/device/authorize with the user code and action: 'approve'. The next poll from the device returns { authorized: true, user_id: '...' }.

User code format

User codes use the format XXXX-XXXX — two four-character segments separated by a hyphen. The character set is consonants only (BCDFGHJKLMNPQRSTVWXZ), which avoids:

  • Visually ambiguous characters (no 0/O, 1/I, 5/S)
  • Characters that read awkwardly when pronounced aloud

The alphabet and segment length are fixed. The codeLength option controls the length of each segment (default: 4).

Input on the approval page is case-insensitive and whitespace-tolerant — bdfk rstv, BDFK-RSTV, and BDFKRSTV all resolve to the same grant.

Polling interval and slow-down

The initial interval is returned by the /auth/device/code endpoint (default: 5 seconds). The server enforces a minimum gap between polls. If the device polls too quickly, the response includes error: 'slow_down' and an updated interval value. The device must use the new interval for all subsequent polls.

Polling too frequently will trigger slow_down responses. Use the interval from the initial response as your starting poll delay, and always update it when you receive slow_down.

Endpoints

EndpointMethodAuth requiredDescription
/auth/device/codePOSTNoStart a new device flow — returns device code and user code
/auth/device/tokenPOSTNoPoll for authorization status
/auth/device/authorizePOSTYesUser approves or denies a device code

/auth/device/code response

FieldTypeDescription
device_codestringOpaque code used for polling. Keep this secret on the device
user_codestringShort human-readable code shown to the user (e.g. BDFK-RSTV)
verification_uristringURL the user visits to approve
verification_uri_completestringSame URL with user_code as a query param — useful for QR codes
expires_innumberSeconds until the codes expire (default: 900)
intervalnumberMinimum seconds between poll requests (default: 5)

/auth/device/token error codes

errorMeaning
authorization_pendingUser has not acted yet — keep polling
slow_downPolling too fast — use the new interval in the response
access_deniedUser denied the request — stop polling
expired_tokenCode has expired — start over with a new code

Configuration reference

OptionTypeDefaultDescription
verificationUristringrequiredURL the user visits to enter the code and approve
codeLengthnumber4Length of each segment in the user code (4XXXX-XXXX)
codeExpirySecondsnumber900How long the device code and user code stay valid
pollIntervalSecondsnumber5Minimum interval between polling attempts, returned to the device

On this page