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) — 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

Rate limits and abuse blocks are counted per network address group, not per exact address:

  • IPv4 — the individual address.
  • IPv6 — the /64 prefix. 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 429 response always carries Retry-After plus the X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers. Honour Retry-After rather than retrying on a fixed interval.

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 atomically with token issuance — a second poll after success returns invalid_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 the device_code for 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_code poll returns invalid_grant — it never reveals that the code exists.

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"