OAuth 2.0 Device Flow
GitHub-style device authorization for CLIs, installers, and limited-input devices
The OAuth 2.0 Device Authorization Grant (Device Flow) allows applications running on devices with limited input capabilities—such as CLIs, installers, smart TVs, or IoT devices—to obtain user authorization without requiring a web browser on the device itself.
How It Works
- Device initiates: Your CLI/app requests a device code and user code
- User approves: User visits a URL and enters the short user code
- Device polls: Your app polls the token endpoint until approved
- Token issued: Once approved, your app receives an access token
This is the same flow used by GitHub CLI (gh auth login), Netlify CLI, and other developer tools.
Quick Start
Step 1: Request Device and User Codes
curl -X POST https://auth.api.us.23blocks.com/device/authorize \
-H "Content-Type: application/json" \
-d '{
"client_id": "your_client_id",
"scope": "read:user write:projects"
}'
Response:
{
"device_code": "NGU5OWE4YjgtM2E3My00ZTg3LWJhNmEtNjJhMmNi...",
"user_code": "ABCD-1234",
"verification_uri": "https://23blocks.com/device",
"verification_uri_complete": "https://23blocks.com/device?user_code=ABCD-1234",
"expires_in": 600,
"interval": 5
}
Step 2: Display Instructions to User
Show the user the user_code and verification_uri:
To authorize this device, visit:
https://23blocks.com/device
And enter the code: ABCD-1234
This code expires in 10 minutes.
Tip: Use verification_uri_complete to generate a QR code or clickable link that pre-fills the user code.
Step 3: Poll for Authorization
Poll the token endpoint every interval seconds (5 seconds by default) until the user approves or denies, or the code expires.
curl -X POST https://auth.api.us.23blocks.com/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": "NGU5OWE4YjgtM2E3My00ZTg3LWJhNmEtNjJhMmNi..."
}'
Response (pending):
{
"error": "authorization_pending",
"error_description": "The authorization request is still pending."
}
Response (success):
{
"access_token": "at_live_abc123...",
"token_type": "Bearer",
"expires_in": 300,
"scope": "read:user write:projects"
}
Endpoint Reference
POST /device/authorize
Initiates a device authorization flow and returns a device code and user code.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
client_id | string | No | Your application's client ID (optional for public clients) |
scope | string | No | Space-separated list of requested scopes |
Response:
| Field | Type | Description |
|---|---|---|
device_code | string | Device verification code (43-char urlsafe_base64, one-time use) |
user_code | string | Short code for user to enter (format: ABCD-1234) |
verification_uri | string | URL where user enters the code |
verification_uri_complete | string | URL with user_code pre-filled (for QR codes/links) |
expires_in | number | Seconds until codes expire (600 = 10 minutes) |
interval | number | Minimum seconds between polling requests (5) |
Example:
const response = await fetch('https://auth.api.us.23blocks.com/device/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: 'cli_app_123',
scope: 'read:user write:projects'
})
});
const { device_code, user_code, verification_uri, interval } = await response.json();
console.log(`Visit ${verification_uri} and enter code: ${user_code}`);
GET/POST /device/verify
User-facing endpoint for entering and approving/denying the device authorization.
Authentication: Requires valid Bearer token (user must be logged in)
Request:
| Field | Type | Required | Description |
|---|---|---|---|
user_code | string | Yes | The user code displayed by the device |
action_type | string | Yes | Either 'approve' or 'deny' |
Response: JSON:API format with status
Example (browser/frontend):
// After user enters ABCD-1234 and clicks "Approve"
const response = await fetch('https://auth.api.us.23blocks.com/device/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userAccessToken}`
},
body: JSON.stringify({
user_code: 'ABCD-1234',
action_type: 'approve'
})
});
POST /oauth/token
Exchanges the device code for an access token. The device should poll this endpoint until the user approves, denies, or the code expires.
Request:
| Field | Type | Required | Description |
|---|---|---|---|
grant_type | string | Yes | Must be 'urn:ietf:params:oauth:grant-type:device_code' |
device_code | string | Yes | The device code from /device/authorize |
Success Response:
| Field | Type | Description |
|---|---|---|
access_token | string | The access token |
token_type | string | Always 'Bearer' |
expires_in | number | Seconds until token expires (300 = 5 minutes) |
scope | string | Granted scopes |
Example:
import time
import requests
# After getting device_code and showing user_code to user
while True:
response = requests.post('https://auth.api.us.23blocks.com/oauth/token', json={
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
'device_code': device_code
})
data = response.json()
if 'access_token' in data:
# Success! User approved
print(f"Authenticated! Token: {data['access_token']}")
break
elif data.get('error') == 'authorization_pending':
# Still waiting for user
time.sleep(interval)
elif data.get('error') == 'slow_down':
# Polling too fast
interval += 5
time.sleep(interval)
else:
# Denied or expired
print(f"Authorization failed: {data.get('error')}")
break
Error Codes
All errors follow RFC 8628 (OAuth 2.0 Device Authorization Grant).
| Error Code | Description | Action |
|---|---|---|
authorization_pending | User hasn't approved yet | Continue polling |
slow_down | Polling too fast | Add 5 seconds to your interval |
access_denied | User denied the request | Stop polling, show error to user |
expired_token | Device code expired (>10 min) | Restart flow with new device code |
invalid_grant | Invalid or already-used device code | Restart flow with new device code |
Example Error Response:
{
"error": "slow_down",
"error_description": "You are polling too frequently. Please slow down."
}
Rate Limits
- 20 requests per 10 minutes per IP address
- 30 requests per 10 minutes per API key/client_id
Exceeding these limits returns HTTP 429 with a Retry-After header.
Flow Diagram
┌─────────┐ ┌────────────┐
│ CLI/App │ │ Auth API │
└────┬────┘ └─────┬──────┘
│ │
│ POST /device/authorize │
│─────────────────────────────────────────────>│
│ │
│ device_code, user_code, verification_uri │
│<─────────────────────────────────────────────│
│ │
│ │
┌─┴──────────────────────────────────┐ │
│ Show to user: │ │
│ Visit: https://23blocks.com/device │ │
│ Code: ABCD-1234 │ │
└─────────────────────────────────────┘ │
│ │
│ (User opens browser and logs in) │
│ ┌────┴─────┐
│ │ User │
│ │ Browser │
│ └────┬─────┘
│ │
│ │ GET /device?user_code=ABCD-1234
│ │
│ │ (Shows approval page)
│ │
│ │ POST /device/verify
│ │ {user_code, action_type: 'approve'}
│ │
│ │ ✓ Approved
│ │
│ POST /oauth/token (polling every 5s) │
│─────────────────────────────────────────────>│
│ │
│ {error: "authorization_pending"} │
│<─────────────────────────────────────────────│
│ │
│ ... wait 5s ... │
│ │
│ POST /oauth/token │
│─────────────────────────────────────────────>│
│ │
│ {access_token, expires_in, scope} │
│<─────────────────────────────────────────────│
│ │
┌─┴─────────────────┐ │
│ ✓ Authenticated! │ │
│ Store token │ │
└────────────────────┘ │
Code Examples
JavaScript/Node.js
async function authenticateDevice() {
// Step 1: Request device code
const authResponse = await fetch('https://auth.api.us.23blocks.com/device/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: 'my_cli_app',
scope: 'read:user write:projects'
})
});
const { device_code, user_code, verification_uri, interval } = await authResponse.json();
// Step 2: Show user code
console.log('\nTo authorize this CLI, visit:');
console.log(` ${verification_uri}`);
console.log(`\nAnd enter the code: ${user_code}\n`);
console.log('Waiting for authorization...');
// Step 3: Poll for token
let pollInterval = interval * 1000; // Convert to ms
while (true) {
await new Promise(resolve => setTimeout(resolve, pollInterval));
const tokenResponse = await fetch('https://auth.api.us.23blocks.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code
})
});
const data = await tokenResponse.json();
if (data.access_token) {
console.log('✓ Authorization successful!');
return data.access_token;
} else if (data.error === 'authorization_pending') {
// Keep waiting
continue;
} else if (data.error === 'slow_down') {
// Increase interval by 5 seconds
pollInterval += 5000;
continue;
} else {
// Denied or expired
throw new Error(`Authorization failed: ${data.error}`);
}
}
}
// Usage
const token = await authenticateDevice();
// Now use token for authenticated API calls
Python
import time
import requests
def authenticate_device():
# Step 1: Request device code
auth_response = requests.post(
'https://auth.api.us.23blocks.com/device/authorize',
json={
'client_id': 'my_cli_app',
'scope': 'read:user write:projects'
}
)
auth_data = auth_response.json()
device_code = auth_data['device_code']
user_code = auth_data['user_code']
verification_uri = auth_data['verification_uri']
interval = auth_data['interval']
# Step 2: Show user code
print('\nTo authorize this CLI, visit:')
print(f' {verification_uri}')
print(f'\nAnd enter the code: {user_code}\n')
print('Waiting for authorization...')
# Step 3: Poll for token
poll_interval = interval
while True:
time.sleep(poll_interval)
token_response = requests.post(
'https://auth.api.us.23blocks.com/oauth/token',
json={
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
'device_code': device_code
}
)
token_data = token_response.json()
if 'access_token' in token_data:
print('✓ Authorization successful!')
return token_data['access_token']
elif token_data.get('error') == 'authorization_pending':
# Keep waiting
continue
elif token_data.get('error') == 'slow_down':
# Increase interval by 5 seconds
poll_interval += 5
continue
else:
# Denied or expired
raise Exception(f"Authorization failed: {token_data.get('error')}")
# Usage
token = authenticate_device()
# Now use token for authenticated API calls
Go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type DeviceAuthResponse 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"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
Error string `json:"error"`
}
func AuthenticateDevice() (string, error) {
// Step 1: Request device code
authReq := map[string]string{
"client_id": "my_cli_app",
"scope": "read:user write:projects",
}
authBody, _ := json.Marshal(authReq)
authResp, err := http.Post(
"https://auth.api.us.23blocks.com/device/authorize",
"application/json",
bytes.NewBuffer(authBody),
)
if err != nil {
return "", err
}
defer authResp.Body.Close()
var authData DeviceAuthResponse
json.NewDecoder(authResp.Body).Decode(&authData)
// Step 2: Show user code
fmt.Println("\nTo authorize this CLI, visit:")
fmt.Printf(" %s\n", authData.VerificationURI)
fmt.Printf("\nAnd enter the code: %s\n\n", authData.UserCode)
fmt.Println("Waiting for authorization...")
// Step 3: Poll for token
pollInterval := time.Duration(authData.Interval) * time.Second
for {
time.Sleep(pollInterval)
tokenReq := map[string]string{
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": authData.DeviceCode,
}
tokenBody, _ := json.Marshal(tokenReq)
tokenResp, err := http.Post(
"https://auth.api.us.23blocks.com/oauth/token",
"application/json",
bytes.NewBuffer(tokenBody),
)
if err != nil {
return "", err
}
var tokenData TokenResponse
json.NewDecoder(tokenResp.Body).Decode(&tokenData)
tokenResp.Body.Close()
if tokenData.AccessToken != "" {
fmt.Println("✓ Authorization successful!")
return tokenData.AccessToken, nil
} else if tokenData.Error == "authorization_pending" {
// Keep waiting
continue
} else if tokenData.Error == "slow_down" {
// Increase interval by 5 seconds
pollInterval += 5 * time.Second
continue
} else {
// Denied or expired
return "", fmt.Errorf("authorization failed: %s", tokenData.Error)
}
}
}
func main() {
token, err := AuthenticateDevice()
if err != nil {
panic(err)
}
// Now use token for authenticated API calls
fmt.Printf("Token: %s\n", token)
}
Best Practices
1. Respect the Polling Interval
Always wait at least interval seconds between token requests. If you receive a slow_down error, increase your interval by 5 seconds.
let pollInterval = interval;
if (data.error === 'slow_down') {
pollInterval += 5;
}
2. Handle Expiration Gracefully
Device codes expire after 10 minutes. If the user doesn't complete authorization in time, restart the flow with a fresh code.
if (data.error === 'expired_token') {
console.log('Code expired. Please try again.');
// Restart flow
return authenticateDevice();
}
3. Use verification_uri_complete for Better UX
Generate a QR code or clickable link using verification_uri_complete to skip the manual code entry step:
import qrcode from 'qrcode-terminal';
qrcode.generate(verification_uri_complete, { small: true });
console.log(`Or visit: ${verification_uri_complete}`);
4. Store Tokens Securely
Never log tokens or store them in plain text. Use secure storage:
// Good: Use keychain/credential manager
const keytar = require('keytar');
await keytar.setPassword('my-cli-app', 'access-token', token);
// Bad: Don't do this
fs.writeFileSync('token.txt', token); // ❌
console.log(token); // ❌
5. Add a Timeout
Don't poll forever. Set a maximum time or number of attempts:
const MAX_ATTEMPTS = 120; // 10 minutes at 5s intervals
let attempts = 0;
while (attempts < MAX_ATTEMPTS) {
// ... polling logic
attempts++;
}
throw new Error('Authorization timed out');
See Also
- Auth Block Overview - Main authentication documentation
- OIDC Token Endpoint - OAuth 2.0 token exchange and refresh
- Service Tokens (M2M) - Machine-to-machine authentication
- RFC 8628 - OAuth 2.0 Device Authorization Grant
Questions?
For questions about this feature, contact support@23blocks.com or visit our support page.