POST /device/code to get user_code + verification_uri, then poll POST /token with grant_type=urn:ietf:params:oauth:grant-type:device_codeinterval triggers slow_down which cumulatively adds 5 seconds per violation.interval value (default: 5 seconds). Each slow_down error adds 5 seconds cumulatively. [src1]device_code expires after expires_in seconds (typically 900-1800s). After expiry, the client MUST restart the entire flow. [src1]user_code MUST be displayed exactly as received. Comparison should be case-insensitive and strip non-alphanumeric characters, but display must preserve the original format. [src1]grant_type value MUST be the full URN string urn:ietf:params:oauth:grant-type:device_code — using any abbreviation will fail. [src1]client_secret in distributed device applications. [src6]| Step | Endpoint | Method | Key Parameters | Response Fields | Notes |
|---|---|---|---|---|---|
| 1. Request device code | /device/code | POST | client_id, scope | device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval | Content-Type: application/x-www-form-urlencoded |
| 2. Display to user | N/A (device screen) | — | — | — | Show user_code + verification_uri; optionally render QR code |
| 3. User opens browser | verification_uri | GET (browser) | — | — | User navigates on phone/laptop |
| 4. User enters code | verification_uri | POST (browser) | user_code | — | User types the displayed code |
| 5. User authenticates | IdP login page | POST (browser) | credentials | — | Standard login + consent screen |
| 6. Poll for token | /token | POST | grant_type=urn:ietf:params:oauth:grant-type:device_code, device_code, client_id | access_token, refresh_token, token_type, expires_in | Repeat every interval seconds |
| 7. Handle authorization_pending | /token | POST | (same) | error: authorization_pending | Keep polling — user hasn't acted yet |
| 8. Handle slow_down | /token | POST | (same) | error: slow_down | Increase interval by 5 seconds, then continue |
| 9. Handle access_denied | /token | POST | (same) | error: access_denied | User denied — stop polling, show error |
| 10. Handle expired_token | /token | POST | (same) | error: expired_token | Code expired — restart from Step 1 |
| 11. Use access token | Resource API | GET/POST | Authorization: Bearer {access_token} | API response | Standard OAuth bearer token usage |
| 12. Refresh token | /token | POST | grant_type=refresh_token, refresh_token, client_id | New access_token | Use when access_token expires |
START — Does the device have a browser and keyboard?
├── YES → Use Authorization Code with PKCE (not device flow)
│
└── NO → Is there a user who needs to authenticate?
├── NO → Use Client Credentials grant (machine-to-machine)
│
└── YES → Can the device display a URL and short code?
├── NO → Consider out-of-band setup (pre-provisioned tokens)
│
└── YES → Use Device Authorization Flow (this unit)
├── Does the IdP support verification_uri_complete?
│ ├── YES → Display QR code (better UX) + fallback text code
│ └── NO → Display verification_uri + user_code as text
│
└── Device type?
├── Smart TV / Game Console → Full device flow with on-screen code
├── CLI Tool → Print code to terminal, open browser with xdg-open/open
└── IoT / Headless → Send code via display, LED, or companion app
Register a public client application with your OAuth2 provider. Enable the Device Authorization Grant. Note your client_id — you will not have a client_secret for public clients. [src2]
# Auth0: Enable Device Code grant in Application Settings > Advanced > Grant Types
# Microsoft: Register app in Azure Portal > App registrations > Authentication > Allow public client flows = Yes
# Google: Create OAuth credentials at console.cloud.google.com > APIs & Services > Credentials
Verify: Check your IdP dashboard confirms Device Code grant is enabled for the application.
Send a POST request to the device authorization endpoint with your client_id and desired scope. [src1]
curl -X POST "https://YOUR_IDP/device/code" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID" \
-d "scope=openid profile email"
Verify: Response includes device_code, user_code, verification_uri, expires_in, and interval.
Show the user_code and verification_uri on the device screen. If verification_uri_complete is available, render a QR code for it. [src1] [src6]
Verify: User can visually read the code and URL from the device screen.
Start polling the token endpoint at the interval specified in the device code response. Handle all error codes correctly. [src1] [src3]
import time
import requests
def poll_for_token(token_url, device_code, client_id, interval=5):
"""Poll token endpoint until user authorizes or code expires."""
while True:
time.sleep(interval)
resp = requests.post(token_url, data={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code,
"client_id": client_id,
})
data = resp.json()
if resp.status_code == 200:
return data # Success
error = data.get("error")
if error == "authorization_pending":
continue
elif error == "slow_down":
interval += 5 # MUST increase by 5 seconds
elif error == "access_denied":
raise Exception("User denied authorization")
elif error == "expired_token":
raise Exception("Device code expired — restart flow")
else:
raise Exception(f"Unexpected error: {error}")
Verify: poll_for_token() returns a dict with access_token after user completes browser authorization.
Store the access_token and refresh_token securely. Use the access token for API calls. [src3]
tokens = poll_for_token(token_url, device_code, client_id, interval)
access_token = tokens["access_token"]
headers = {"Authorization": f"Bearer {access_token}"}
api_response = requests.get("https://api.example.com/resource", headers=headers)
Verify: API call returns 200 OK with the protected resource data.
When the access token expires, use the refresh token to obtain a new one without re-running the device flow. [src3]
def refresh_access_token(token_url, refresh_token, client_id):
resp = requests.post(token_url, data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
})
if resp.status_code == 200:
return resp.json()
raise Exception(f"Token refresh failed: {resp.json()}")
Verify: New access_token and optionally new refresh_token returned.
# Input: client_id, scopes, IdP endpoints
# Output: access_token + refresh_token
import time
import requests
import sys
class DeviceFlowClient:
def __init__(self, client_id, device_code_url, token_url):
self.client_id = client_id
self.device_code_url = device_code_url
self.token_url = token_url
def authenticate(self, scope="openid profile"):
# Step 1: Request device code
resp = requests.post(self.device_code_url, data={
"client_id": self.client_id,
"scope": scope,
})
resp.raise_for_status()
auth = resp.json()
# Step 2: Display instructions
print(f"\nTo sign in, visit: {auth['verification_uri']}")
print(f"Enter code: {auth['user_code']}\n")
if "verification_uri_complete" in auth:
print(f"Or open: {auth['verification_uri_complete']}\n")
# Step 3: Poll for token
interval = auth.get("interval", 5)
expires_at = time.time() + auth["expires_in"]
while time.time() < expires_at:
time.sleep(interval)
token_resp = requests.post(self.token_url, data={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": auth["device_code"],
"client_id": self.client_id,
})
data = token_resp.json()
if token_resp.status_code == 200:
return data
error = data.get("error")
if error == "authorization_pending":
continue
elif error == "slow_down":
interval += 5
elif error == "access_denied":
sys.exit("Authorization denied by user.")
elif error == "expired_token":
sys.exit("Code expired. Please restart.")
else:
sys.exit(f"Error: {error}: {data.get('error_description')}")
sys.exit("Code expired before authorization completed.")
// Input: clientId, scopes, IdP endpoints
// Output: { access_token, refresh_token, expires_in }
async function deviceFlowAuth(clientId, deviceCodeUrl, tokenUrl, scope = "openid profile") {
// Step 1: Request device code
const codeResp = await fetch(deviceCodeUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: clientId, scope }),
});
const auth = await codeResp.json();
// Step 2: Display instructions
console.log(`\nTo sign in, visit: ${auth.verification_uri}`);
console.log(`Enter code: ${auth.user_code}\n`);
// Step 3: Poll for token
let interval = (auth.interval || 5) * 1000;
const deadline = Date.now() + auth.expires_in * 1000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, interval));
const tokenResp = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: auth.device_code,
client_id: clientId,
}),
});
const data = await tokenResp.json();
if (tokenResp.ok) return data;
switch (data.error) {
case "authorization_pending": continue;
case "slow_down": interval += 5000; continue;
case "access_denied": throw new Error("User denied authorization");
case "expired_token": throw new Error("Device code expired");
default: throw new Error(`${data.error}: ${data.error_description}`);
}
}
throw new Error("Device code expired before user authorized");
}
// Input: clientID, scopes, IdP endpoints
// Output: TokenResponse with AccessToken, RefreshToken
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
type DeviceCodeResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
func DeviceFlowAuth(clientID, deviceCodeURL, tokenURL, scope string) (*TokenResponse, error) {
resp, err := http.PostForm(deviceCodeURL, url.Values{
"client_id": {clientID},
"scope": {scope},
})
if err != nil {
return nil, fmt.Errorf("device code request failed: %w", err)
}
defer resp.Body.Close()
var auth DeviceCodeResponse
json.NewDecoder(resp.Body).Decode(&auth)
fmt.Printf("\nTo sign in, visit: %s\n", auth.VerificationURI)
fmt.Printf("Enter code: %s\n\n", auth.UserCode)
interval := time.Duration(auth.Interval) * time.Second
if interval == 0 {
interval = 5 * time.Second
}
deadline := time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second)
for time.Now().Before(deadline) {
time.Sleep(interval)
tokenResp, err := http.PostForm(tokenURL, url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"device_code": {auth.DeviceCode},
"client_id": {clientID},
})
if err != nil {
interval *= 2
continue
}
defer tokenResp.Body.Close()
var token TokenResponse
json.NewDecoder(tokenResp.Body).Decode(&token)
if tokenResp.StatusCode == 200 {
return &token, nil
}
switch token.Error {
case "authorization_pending":
continue
case "slow_down":
interval += 5 * time.Second
case "access_denied":
return nil, fmt.Errorf("user denied authorization")
case "expired_token":
return nil, fmt.Errorf("device code expired")
default:
return nil, fmt.Errorf("%s: %s", token.Error, token.ErrorDesc)
}
}
return nil, fmt.Errorf("device code expired before user authorized")
}
# BAD — polls as fast as possible, will trigger slow_down and get rate-limited
while True:
resp = requests.post(token_url, data=token_data)
if resp.status_code == 200:
break
# GOOD — respects server-specified interval and handles slow_down
interval = auth.get("interval", 5)
while True:
time.sleep(interval) # Always wait BEFORE polling
resp = requests.post(token_url, data=token_data)
data = resp.json()
if resp.status_code == 200:
break
if data.get("error") == "slow_down":
interval += 5 # Cumulative increase per RFC 8628
// BAD — treats slow_down same as authorization_pending
if (data.error === "authorization_pending" || data.error === "slow_down") {
continue; // interval stays the same — server will keep returning slow_down
}
// GOOD — adds 5 seconds on slow_down per RFC 8628 Section 3.5
if (data.error === "slow_down") {
interval += 5000; // cumulative +5s in milliseconds
continue;
}
# BAD — verification_uri may change; different tenants have different URIs
VERIFY_URL = "https://microsoft.com/devicelogin"
print(f"Go to {VERIFY_URL}")
# GOOD — always use the URI returned by the authorization server
auth = request_device_code(client_id, scope)
print(f"Go to {auth['verification_uri']}") # Dynamic from server response
# BAD — device apps are public clients; secrets can be extracted from binaries
resp = requests.post(device_code_url, data={
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET, # NEVER do this on a distributed device
"scope": "openid",
})
# GOOD — device flow is designed for public clients (no secret)
resp = requests.post(device_code_url, data={
"client_id": CLIENT_ID, # Only client_id, no secret
"scope": "openid",
})
interval seconds before the first poll, not just between polls. Polling immediately after receiving the device code wastes a request. Fix: time.sleep(interval) before the first token request. [src1]verification_uri_complete and render a QR code when available. [src4]expires_in and break the loop with a clear error when the deadline passes. [src1]urn:ietf:params:oauth:grant-type:device_code, not device_code or device_authorization. Fix: use the exact string from RFC 8628. [src1]offline_access in the scope parameter. [src3]user_code exactly as received. [src1]# Test device code endpoint (replace with your IdP URL and client_id)
curl -s -X POST "https://YOUR_IDP/device/code" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID&scope=openid" | jq .
# Test token polling (will return authorization_pending before user acts)
curl -s -X POST "https://YOUR_IDP/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=YOUR_DEVICE_CODE&client_id=YOUR_CLIENT_ID" | jq .
# Verify access token (decode JWT without verification — for debugging only)
echo "YOUR_ACCESS_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
# Check token expiry from JWT claims
echo "YOUR_ACCESS_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '.exp | todate'
# Check IdP discovery endpoint for device_authorization_endpoint
curl -s "https://YOUR_IDP/.well-known/openid-configuration" | jq '.device_authorization_endpoint'
| Version / Standard | Status | Key Changes | Notes |
|---|---|---|---|
| RFC 8628 (Aug 2019) | Current, Stable | Finalized the Device Authorization Grant | Replaced draft-ietf-oauth-device-flow |
| Draft -15 (2018) | Superseded | Last draft before RFC publication | Minor editorial changes from draft to RFC |
| Google Device Flow (2016+) | Proprietary predecessor | Pre-RFC implementation; now aligned with RFC 8628 | Uses https://oauth2.googleapis.com/device/code |
| Microsoft Device Code Flow | RFC 8628 aligned | Does not support verification_uri_complete | Uses https://login.microsoftonline.com/{tenant}/oauth2/v2.0/devicecode |
| Auth0 Device Flow | RFC 8628 aligned | Full RFC support including verification_uri_complete | Uses https://{domain}/oauth/device/code |
| GitHub Device Flow | RFC 8628 aligned | Used for CLI authentication (gh auth login) | Uses https://github.com/login/device/code |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Smart TV, streaming device, game console needs user auth | Device has a full browser and keyboard | Authorization Code with PKCE |
| CLI tool needs to authenticate a human user | Service-to-service (no human user) | Client Credentials grant |
| IoT device with a screen but no keyboard | Mobile app with WebView support | Authorization Code with PKCE (mobile) |
| Digital signage or kiosk in restricted input mode | Single-page web application | Authorization Code with PKCE (SPA) |
| Set-top box or media player needs user login | Server-side web application | Authorization Code (standard) |
| Headless device that can display/communicate a code | Device with no display and no way to show code | Pre-provisioned credentials or bootstrap token |
/.well-known/openid-configuration discovery document for the device_authorization_endpoint field rather than hardcoding paths.verification_uri_complete field, so QR code flows require manual construction.