Skip to main content

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​

  1. Device initiates: Your CLI/app requests a device code and user code
  2. User approves: User visits a URL and enters the short user code
  3. Device polls: Your app polls the token endpoint until approved
  4. 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:

FieldTypeRequiredDescription
client_idstringNoYour application's client ID (optional for public clients)
scopestringNoSpace-separated list of requested scopes

Response:

FieldTypeDescription
device_codestringDevice verification code (43-char urlsafe_base64, one-time use)
user_codestringShort code for user to enter (format: ABCD-1234)
verification_uristringURL where user enters the code
verification_uri_completestringURL with user_code pre-filled (for QR codes/links)
expires_innumberSeconds until codes expire (600 = 10 minutes)
intervalnumberMinimum 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:

FieldTypeRequiredDescription
user_codestringYesThe user code displayed by the device
action_typestringYesEither '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:

FieldTypeRequiredDescription
grant_typestringYesMust be 'urn:ietf:params:oauth:grant-type:device_code'
device_codestringYesThe device code from /device/authorize

Success Response:

FieldTypeDescription
access_tokenstringThe access token
token_typestringAlways 'Bearer'
expires_innumberSeconds until token expires (300 = 5 minutes)
scopestringGranted 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 CodeDescriptionAction
authorization_pendingUser hasn't approved yetContinue polling
slow_downPolling too fastAdd 5 seconds to your interval
access_deniedUser denied the requestStop polling, show error to user
expired_tokenDevice code expired (>10 min)Restart flow with new device code
invalid_grantInvalid or already-used device codeRestart 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​

Questions?​

For questions about this feature, contact support@23blocks.com or visit our support page.