OAuth2 Device Authorization Flow: Complete Reference

Type: Software Reference Confidence: 0.90 Sources: 7 Verified: 2026-02-24 Freshness: 2026-02-24

TL;DR

Constraints

Quick Reference

StepEndpointMethodKey ParametersResponse FieldsNotes
1. Request device code/device/codePOSTclient_id, scopedevice_code, user_code, verification_uri, verification_uri_complete, expires_in, intervalContent-Type: application/x-www-form-urlencoded
2. Display to userN/A (device screen)Show user_code + verification_uri; optionally render QR code
3. User opens browserverification_uriGET (browser)User navigates on phone/laptop
4. User enters codeverification_uriPOST (browser)user_codeUser types the displayed code
5. User authenticatesIdP login pagePOST (browser)credentialsStandard login + consent screen
6. Poll for token/tokenPOSTgrant_type=urn:ietf:params:oauth:grant-type:device_code, device_code, client_idaccess_token, refresh_token, token_type, expires_inRepeat every interval seconds
7. Handle authorization_pending/tokenPOST(same)error: authorization_pendingKeep polling — user hasn't acted yet
8. Handle slow_down/tokenPOST(same)error: slow_downIncrease interval by 5 seconds, then continue
9. Handle access_denied/tokenPOST(same)error: access_deniedUser denied — stop polling, show error
10. Handle expired_token/tokenPOST(same)error: expired_tokenCode expired — restart from Step 1
11. Use access tokenResource APIGET/POSTAuthorization: Bearer {access_token}API responseStandard OAuth bearer token usage
12. Refresh token/tokenPOSTgrant_type=refresh_token, refresh_token, client_idNew access_tokenUse 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

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 / StandardStatusKey ChangesNotes
RFC 8628 (Aug 2019)Current, StableFinalized the Device Authorization GrantReplaced draft-ietf-oauth-device-flow
Draft -15 (2018)SupersededLast draft before RFC publicationMinor editorial changes from draft to RFC
Google Device Flow (2016+)Proprietary predecessorPre-RFC implementation; now aligned with RFC 8628Uses https://oauth2.googleapis.com/device/code
Microsoft Device Code FlowRFC 8628 alignedDoes not support verification_uri_completeUses https://login.microsoftonline.com/{tenant}/oauth2/v2.0/devicecode
Auth0 Device FlowRFC 8628 alignedFull RFC support including verification_uri_completeUses https://{domain}/oauth/device/code
GitHub Device FlowRFC 8628 alignedUsed for CLI authentication (gh auth login)Uses https://github.com/login/device/code

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Smart TV, streaming device, game console needs user authDevice has a full browser and keyboardAuthorization Code with PKCE
CLI tool needs to authenticate a human userService-to-service (no human user)Client Credentials grant
IoT device with a screen but no keyboardMobile app with WebView supportAuthorization Code with PKCE (mobile)
Digital signage or kiosk in restricted input modeSingle-page web applicationAuthorization Code with PKCE (SPA)
Set-top box or media player needs user loginServer-side web applicationAuthorization Code (standard)
Headless device that can display/communicate a codeDevice with no display and no way to show codePre-provisioned credentials or bootstrap token

Important Caveats

Related Units