OAuth2 Device Authorization Flow: Complete Reference
How do I implement OAuth2 Device Authorization flow?
TL;DR
- Bottom line: The Device Authorization Grant (RFC 8628) lets input-constrained devices (smart TVs, CLI tools, IoT) authenticate users by displaying a short code the user enters on a separate browser-capable device.
- Key tool/command:
POST /device/codeto get user_code + verification_uri, then pollPOST /tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_code - Watch out for: Polling the token endpoint too fast — exceeding the
intervaltriggersslow_downwhich cumulatively adds 5 seconds per violation. - Works with: Any OAuth 2.0 provider supporting RFC 8628 (Auth0, Microsoft Entra ID, Google, Okta, Keycloak, GitHub).
Constraints
- Polling interval MUST respect the server-returned
intervalvalue (default: 5 seconds). Eachslow_downerror adds 5 seconds cumulatively. [src1] - The
device_codeexpires afterexpires_inseconds (typically 900-1800s). After expiry, the client MUST restart the entire flow. [src1] - The
user_codeMUST be displayed exactly as received. Comparison should be case-insensitive and strip non-alphanumeric characters, but display must preserve the original format. [src1] - The
grant_typevalue MUST be the full URN stringurn:ietf:params:oauth:grant-type:device_code— using any abbreviation will fail. [src1] - On connection timeout during polling, clients MUST apply exponential backoff (recommended: double the interval). [src1]
- Device flow clients are public clients (cannot store secrets securely) — never embed
client_secretin distributed device applications. [src6]
Quick Reference
| 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 |
Decision Tree
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
Step-by-Step Guide
1. Register your application with the identity provider
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.
2. Request a device code from the authorization server
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.
3. Display the user code and verification URI
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.
4. Poll the token endpoint for authorization
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.
5. Handle the successful token response
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.
6. Implement token refresh
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.
Code Examples
Python: Complete Device Flow Client
# 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.")
Node.js: Complete Device Flow Client
// 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");
}
Go: Complete Device Flow Client
// 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")
}
Anti-Patterns
Wrong: Polling without respecting the interval
# 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
Correct: Always sleep for the specified interval before each poll
# 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
Wrong: Ignoring the slow_down error
// 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
}
Correct: Incrementing interval on 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;
}
Wrong: Hardcoding the verification URI
# BAD — verification_uri may change; different tenants have different URIs
VERIFY_URL = "https://microsoft.com/devicelogin"
print(f"Go to {VERIFY_URL}")
Correct: Using the URI from the device code response
# 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
Wrong: Embedding client_secret in distributed device apps
# 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",
})
Correct: Using public client flow without secrets
# 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",
})
Common Pitfalls
- Polling before the first interval elapses: The RFC requires waiting
intervalseconds 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] - Not handling verification_uri_complete: Many IdPs return this field which includes the user_code in the URL. Displaying a QR code for it dramatically improves UX on TVs. Fix: check for
verification_uri_completeand render a QR code when available. [src4] - Forgetting to check expires_in: If the user never completes authorization, your polling loop runs forever. Fix: track
expires_inand break the loop with a clear error when the deadline passes. [src1] - Using wrong grant_type string: The grant type is the full URN
urn:ietf:params:oauth:grant-type:device_code, notdevice_codeordevice_authorization. Fix: use the exact string from RFC 8628. [src1] - Not requesting offline_access scope: Without this scope, many providers won't issue a refresh_token, forcing the user to re-authenticate when the access_token expires. Fix: include
offline_accessin the scope parameter. [src3] - Displaying user_code in lowercase: Some providers generate case-sensitive codes. Even if comparison is case-insensitive, display must match the server response. Fix: display
user_codeexactly as received. [src1] - Not implementing exponential backoff on network errors: Connection timeouts during polling should trigger backoff, not just retry at the same interval. Fix: double the interval on each consecutive network error. [src1]
Diagnostic Commands
# 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 History & Compatibility
| 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 |
When to Use / When Not to Use
| 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 |
Important Caveats
- Provider endpoint differences: Each IdP uses slightly different endpoint paths. Always check the
/.well-known/openid-configurationdiscovery document for thedevice_authorization_endpointfield rather than hardcoding paths. - Scope limitations: Google's device flow supports only a limited set of scopes. Not all OAuth scopes available in other flows may work with device flow. Check your IdP's documentation for device-flow-specific scope restrictions.
- No verification_uri_complete on Microsoft: Microsoft Entra ID does not support the optional
verification_uri_completefield, so QR code flows require manual construction. - User code entropy: RFC 8628 recommends 8-character base-20 codes (using consonants BCDFGHJKLMNPQRSTVWXZ) for ~34.5 bits of entropy. Shorter or numeric-only codes reduce security against brute-force attacks.
- Phishing risk: An attacker could trick a user into entering a device code on a legitimate verification page, authorizing the attacker's device. Mitigation: display device/location context during the authorization step.
- No PKCE integration: Device flow does not use PKCE. Security relies on the short lifetime of the device code and the entropy of both device_code and user_code.