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.
How it works
Section titled “How it works”┌──────────┐ 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) │ │└──────────┘ └──────────────┘Endpoints
Section titled “Endpoints”| Endpoint | Purpose |
|---|---|
POST /oauth/device/code | Issues 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-server | Advertises 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).
Defaults
Section titled “Defaults”| Setting | Value |
|---|---|
expires_in | 1800s (30 minutes) — longer than the RFC’s 900s suggestion to give phone-bound users typing room |
interval | 5s |
user_code format | 8 chars XXXX-XXXX from a 31-symbol alphabet (no 0/O/1/I/L) |
device_code entropy | 256 bits, base64url-encoded |
| Max active codes per (tenant, client) | 5 — 6th request returns slow_down |
| Max active codes per tenant | 1000 (global ceiling across all clients) |
| Per-IP rate limit | 10 / 15 minutes on /oauth/device/code |
How “per-IP” is counted
Section titled “How “per-IP” is counted”Rate limits and abuse blocks are counted per network address group, not per exact address:
- IPv4 — the individual address.
- IPv6 — the
/64prefix. Addresses that differ only in the last 64 bits share one budget.
This matters if you integrate from IPv6: many operating systems rotate the lower half of an IPv6
address automatically (temporary/privacy addresses), so counting per exact address would let a single
client consume an unbounded number of budgets. Grouping by /64 keeps the published limit meaningful.
Practical consequences for an integrator:
- Several machines behind the same IPv6
/64— a home or office network, a single cloud subnet — share the limit. Spread high-volume traffic across different subnets, not just different hosts. - A
429response always carriesRetry-Afterplus theX-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Resetheaders. HonourRetry-Afterrather than retrying on a fixed interval.
Free for all tiers
Section titled “Free for all tiers”Rakomi includes RFC 8628 in every plan, including Free.
Quick starts
Section titled “Quick starts”Node CLI (with @rakomi/node)
Section titled “Node CLI (with @rakomi/node)”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).
Python (with requests)
Section titled “Python (with requests)”import timeimport 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")) breakAI agent (Claude / Cursor / MCP) shell snippet
Section titled “AI agent (Claude / Cursor / MCP) shell snippet”# 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 <<EOFTo grant this agent access to your Rakomi account, open this URL on any device: $VERIFY_URLAnd enter the code: $USER_CODEEOF
# 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 ;; esacdoneSecurity model
Section titled “Security model”- 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_codeis consumed atomically with token issuance — a second poll after success returnsinvalid_grant, with no window in which the same code mints twice. RFC 8628 §3.5. - Constant-time client_secret comparison. Confidential clients’ secrets are compared in constant time against a memory-hard hash — never in plaintext and never with a short-circuiting comparison. During a secret rotation the previous secret keeps working until the rotation window closes, so a rotation never strands a live client.
- Audit trail. Every issuance, approval, denial, expiry, and rate-limit
rejection emits a structured
oauth.device.*event carrying only a short one-way digest of thedevice_codefor forensic correlation. Raw codes are never logged. - Multi-tenant isolation. Device-authorization state is tenant-scoped at
two independent layers (defence in depth), so a cross-tenant
device_codepoll returnsinvalid_grant— it never reveals that the code exists.
Client registration
Section titled “Client registration”Enable the device-grant on a new or existing OAuth client:
- Dashboard → OAuth Clients → Add OAuth Client (or edit an existing one).
- Check Device Authorization Grant (RFC 8628) in the Grant types list.
- 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 supplyclient_secretin the body or HTTP Basic header.
Discovery
Section titled “Discovery”The endpoint is advertised in the standard authorization-server metadata:
curl https://api.rakomi.com/.well-known/oauth-authorization-server | jq .device_authorization_endpoint# → "https://api.rakomi.com/oauth/device/code"Spec references
Section titled “Spec references”- RFC 8628 — OAuth 2.0 Device Authorization Grant
- RFC 6749 §5.2 — OAuth error response format
- RFC 8414 — Authorization Server Metadata
- OpenID Connect Core 1.0 §16.2 —
noncereplay defence (Rakomi propagatesnoncefrom/oauth/device/codeinto the issuedid_token)