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
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.
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.
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.
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
| Endpoint | Method | Auth required | Description |
|---|---|---|---|
/auth/device/code | POST | No | Start a new device flow — returns device code and user code |
/auth/device/token | POST | No | Poll for authorization status |
/auth/device/authorize | POST | Yes | User approves or denies a device code |
/auth/device/code response
| Field | Type | Description |
|---|---|---|
device_code | string | Opaque code used for polling. Keep this secret on the device |
user_code | string | Short human-readable code shown to the user (e.g. BDFK-RSTV) |
verification_uri | string | URL the user visits to approve |
verification_uri_complete | string | Same URL with user_code as a query param — useful for QR codes |
expires_in | number | Seconds until the codes expire (default: 900) |
interval | number | Minimum seconds between poll requests (default: 5) |
/auth/device/token error codes
error | Meaning |
|---|---|
authorization_pending | User has not acted yet — keep polling |
slow_down | Polling too fast — use the new interval in the response |
access_denied | User denied the request — stop polling |
expired_token | Code has expired — start over with a new code |
Configuration reference
| Option | Type | Default | Description |
|---|---|---|---|
verificationUri | string | required | URL the user visits to enter the code and approve |
codeLength | number | 4 | Length of each segment in the user code (4 → XXXX-XXXX) |
codeExpirySeconds | number | 900 | How long the device code and user code stay valid |
pollIntervalSeconds | number | 5 | Minimum interval between polling attempts, returned to the device |