Skip to content

Device Authorization Grant (RFC 8628)

The OAuth 2.0 Device Authorization Grant (RFC 8628) lets input-constrained clients — CLIs, AI agents, MCP servers, IoT firmware, smart TVs — authenticate users without embedding a browser, screen-scraping a redirect, or storing user passwords.

┌──────────┐ 1. POST /oauth/device/code ┌──────────────┐
│ Device │ ────────────────────────────► │ Authorization│
│ (CLI/AI) │ ◄──────── device_code, ────── │ Server │
│ │ user_code, │ │
│ │ verification_uri │ │
└──────────┘ └──────────────┘
│ 2. Display user_code + verification_uri to the user.
│ (User opens the URL on phone/laptop.)
User signs in → consent screen → Approve.
│ 3. POST /oauth/token (grant_type=…device_code)
┌──────────┐ ┌──────────────┐
│ Device │ ────────────────────────────► │ Authorization│
│ (polls) │ ◄────── access_token, │ Server │
│ │ refresh_token, │ │
│ │ id_token (if openid) │ │
└──────────┘ └──────────────┘
EndpointPurpose
POST /oauth/device/codeIssues device_code + human-friendly user_code
POST /oauth/token (grant_type=urn:ietf:params:oauth:grant-type:device_code)Polls for the user’s approval
GET /.well-known/oauth-authorization-serverAdvertises the device-authorization endpoint per RFC 8414

Both endpoints accept application/x-www-form-urlencoded and follow the RFC 6749 §5.2 error envelope (error, error_description, error_uri).

SettingValue
expires_in1800s (30 minutes) — matches Auth0/Google defaults; longer than the RFC’s 900s suggestion to give phone-bound users typing room
interval5s
user_code format8 chars XXXX-XXXX from a 31-symbol alphabet (no 0/O/1/I/L)
device_code entropy256 bits, base64url-encoded
Max active codes per (tenant, client)5 — 6th request returns slow_down
Max active codes per tenant1000 (global ceiling across all clients)
Per-IP rate limit10 / 15 minutes on /oauth/device/code

Unlike Auth0 (which gates device flow behind paid tiers), Rakomi includes RFC 8628 in every plan including Free.

import { runDeviceFlow } from '@rakomi/node';
const result = await runDeviceFlow({
clientId: process.env.RAKOMI_CLIENT_ID!,
scope: 'openid profile email',
onCode: ({ user_code, verification_uri }) => {
console.log(`To sign in, open ${verification_uri} and enter: ${user_code}`);
},
});
if (!result.ok) {
console.error('Device flow failed:', result.error.code, result.error.message);
process.exit(1);
}
const { access_token, refresh_token, id_token } = result.data;
console.log('Signed in. Access token expires in', result.data.expires_in, 's');

The SDK helper handles slow_down (adds 5s to the interval per RFC 8628 §3.5), honors AbortSignal for cancellation, and surfaces typed error codes (device/authorization_pending, device/access_denied, device/expired_token).

import time
import requests
API = "https://api.rakomi.com"
CLIENT_ID = "your_client_id"
# 1. Initiate.
issued = requests.post(
f"{API}/oauth/device/code",
data={"client_id": CLIENT_ID, "scope": "openid profile email"},
).json()
print(f"Open {issued['verification_uri']} and enter: {issued['user_code']}")
# 2. Poll until tokens or terminal error.
interval = issued["interval"]
deadline = time.time() + issued["expires_in"]
while time.time() < deadline:
time.sleep(interval)
res = requests.post(
f"{API}/oauth/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": issued["device_code"],
"client_id": CLIENT_ID,
},
)
body = res.json()
if res.ok:
print("Signed in.", body["access_token"][:20], "")
break
err = body.get("error")
if err == "authorization_pending":
continue
if err == "slow_down":
interval += 5 # RFC 8628 §3.5
continue
print("Failed:", err, body.get("error_description"))
break

AI agent (Claude / Cursor / MCP) shell snippet

Section titled “AI agent (Claude / Cursor / MCP) shell snippet”
Terminal window
# Initiate.
ISSUED=$(curl -sX POST https://api.rakomi.com/oauth/device/code \
-d "client_id=$RAKOMI_CLIENT_ID" \
-d 'scope=openid profile email')
USER_CODE=$(echo "$ISSUED" | jq -r .user_code)
VERIFY_URL=$(echo "$ISSUED" | jq -r .verification_uri)
DEVICE_CODE=$(echo "$ISSUED" | jq -r .device_code)
INTERVAL=$(echo "$ISSUED" | jq -r .interval)
cat <<EOF
To grant this agent access to your Rakomi account, open this URL on any device:
$VERIFY_URL
And enter the code: $USER_CODE
EOF
# Poll.
while sleep "$INTERVAL"; do
RES=$(curl -sX POST https://api.rakomi.com/oauth/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
-d "device_code=$DEVICE_CODE" \
-d "client_id=$RAKOMI_CLIENT_ID")
ERR=$(echo "$RES" | jq -r '.error // empty')
case "$ERR" in
authorization_pending) continue ;;
slow_down) INTERVAL=$((INTERVAL + 5)); continue ;;
"") export ACCESS_TOKEN=$(echo "$RES" | jq -r .access_token); break ;;
*) echo "Auth failed: $ERR"; exit 1 ;;
esac
done
  • Anti-phishing. The consent screen always shows the requesting client’s name and the exact scopes — never auto-approves. RFC 8628 §5.3 mitigation.
  • Single-use. Each device_code is consumed in the same DB transaction that mints tokens (SELECT FOR UPDATE + DELETE). A second poll after success returns invalid_grant. RFC 8628 §3.5.
  • Constant-time client_secret comparison. Confidential clients have their secret verified via crypto.timingSafeEqual against an Argon2id-hashed store, with a fallback to the previous-secret-hash within the rotation window.
  • Audit trail. Every issuance, approval, denial, expiry, and rate-limit rejection emits a structured oauth.device.* event with a 12-char SHA-256 prefix of the device_code for forensic correlation. Raw codes are never logged.
  • Multi-tenant isolation. All device-authorization rows are tenant-scoped via PostgreSQL RLS (the belt) plus app-level withTenant (the suspenders). Cross-tenant device_code polls return invalid_grant.

Enable the device-grant on a new or existing OAuth client:

  1. Dashboard → OAuth ClientsAdd OAuth Client (or edit an existing one).
  2. Check Device Authorization Grant (RFC 8628) in the Grant types list.
  3. For CLIs / AI agents / IoT firmware: also set Client type to Public (no client_secret). For server-side confidential apps that want device flow: keep Confidential and supply client_secret in the body or HTTP Basic header.

The endpoint is advertised in the standard authorization-server metadata:

Terminal window
curl https://api.rakomi.com/.well-known/oauth-authorization-server | jq .device_authorization_endpoint
# → "https://api.rakomi.com/oauth/device/code"