Keycloak OAuth Device Flow for IoT and CLI Apps

Guilliano Molaire Guilliano Molaire Updated June 1, 2026 10 min read

Last updated: March 2026

Some devices cannot display a browser or accept keyboard input. Smart TVs, IoT sensors, CLI tools, game consoles, and embedded devices all need to authenticate users but lack the interface for traditional OAuth flows. The OAuth 2.0 Device Authorization Grant (RFC 8628) solves this by allowing the user to authenticate on a separate device — like their phone or laptop — while the input-constrained device polls for completion.

Keycloak has supported the device authorization grant since version 19 (see the Keycloak device flow documentation). This guide covers the full implementation: Keycloak configuration, the device flow protocol, and a complete Python CLI tool example.

How the Device Flow Works

The device authorization grant involves five steps:

  1. Device requests authorization: The device (CLI tool, IoT sensor, TV app) sends a request to Keycloak’s device authorization endpoint. It receives a user code and a verification URL.

  2. User code displayed: The device shows the user code and verification URL to the user (on screen, in the terminal, or on a small display).

  3. User authenticates on another device: The user opens the verification URL on their phone or laptop, enters the user code, and authenticates with Keycloak (including any MFA if configured).

  4. Device polls for token: While the user is authenticating, the device polls Keycloak’s token endpoint at a specified interval, waiting for the user to complete authorization.

  5. Token issued: Once the user approves, the next poll returns the access token and refresh token to the device.

This flow is ideal because the device never handles user credentials directly, and the user authenticates on a device with a full browser where single sign-on and multi-factor authentication work normally.

Keycloak Configuration

Step 1: Enable the Device Authorization Grant

  1. Navigate to your realm in the Keycloak Admin Console.
  2. Go to Clients and click Create client (or edit an existing client).
  3. Configure the client:
    • Client ID: my-cli-tool
    • Client Protocol: openid-connect
  4. On the capability configuration screen:
    • Client Authentication: OFF (public client for CLI/IoT)
    • Standard Flow: OFF
    • Direct Access Grants: OFF
    • OAuth 2.0 Device Authorization Grant: ON
  5. Save the client.

For confidential clients (server-side IoT hubs), set Client Authentication to ON and note the client secret.

Step 2: Configure Device Flow Settings

In the realm settings, you can configure the device flow parameters:

  1. Go to Realm Settings > Tokens.
  2. Find the OAuth 2.0 Device Code settings:
    • OAuth 2.0 Device Code Lifespan: How long the user code is valid (default: 600 seconds / 10 minutes).
    • OAuth 2.0 Device Polling Interval: Minimum interval between polling requests (default: 5 seconds).

These defaults work well for most use cases. The 10-minute code lifespan gives users enough time to find their phone and authenticate, and the 5-second polling interval balances responsiveness with server load.

The Protocol Step by Step

1. Device Authorization Request

The device sends a POST request to the device authorization endpoint:

curl -X POST "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/auth/device" 
  -H "Content-Type: application/x-www-form-urlencoded" 
  -d "client_id=my-cli-tool" 
  -d "scope=openid profile email"

Response:

{
  "device_code": "GmRh...device-code-here",
  "user_code": "WDJB-MJHT",
  "verification_uri": "https://keycloak.example.com/realms/my-realm/device",
  "verification_uri_complete": "https://keycloak.example.com/realms/my-realm/device?user_code=WDJB-MJHT",
  "expires_in": 600,
  "interval": 5
}

Key fields:

  • device_code: The device keeps this secret and uses it to poll for the token.
  • user_code: Displayed to the user. Short and easy to type (format: XXXX-XXXX).
  • verification_uri: The URL the user visits to enter the code.
  • verification_uri_complete: A convenience URL with the user code pre-filled.
  • interval: Minimum seconds between polling requests.

2. Display the Code to the User

The device displays the verification URL and user code. For a CLI tool:

To sign in, open this URL in your browser:

  https://keycloak.example.com/realms/my-realm/device

Enter the code: WDJB-MJHT

Waiting for authentication...

For IoT devices with small displays, you might show only the verification_uri_complete as a QR code.

3. User Authenticates

The user navigates to the verification URL, enters the user code, and authenticates with Keycloak. This happens on the user’s phone or laptop — a device with a full browser. Keycloak handles the entire authentication flow, including any configured identity providers, MFA, or consent screens.

4. Device Polls for Token

While the user is authenticating, the device polls the token endpoint:

curl -X POST "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token" 
  -H "Content-Type: application/x-www-form-urlencoded" 
  -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" 
  -d "client_id=my-cli-tool" 
  -d "device_code=GmRh...device-code-here"

The response depends on the state:

Authorization pending (user has not authenticated yet):

{
  "error": "authorization_pending",
  "error_description": "The authorization request is still pending"
}

Slow down (polling too fast):

{
  "error": "slow_down",
  "error_description": "Polling too frequently"
}

Access denied (user rejected):

{
  "error": "access_denied",
  "error_description": "The end-user denied the authorization request"
}

Expired (user took too long):

{
  "error": "expired_token",
  "error_description": "The device code has expired"
}

Success (user authenticated):

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "openid profile email"
}

Complete Python CLI Tool

Here is a full Python CLI tool that implements the device flow:

#!/usr/bin/env python3
"""CLI tool with Keycloak Device Authorization Grant."""

import json
import sys
import time
import webbrowser
from pathlib import Path

import requests

KEYCLOAK_URL = "https://keycloak.example.com"
REALM = "my-realm"
CLIENT_ID = "my-cli-tool"
TOKEN_FILE = Path.home() / ".my-cli-tool" / "tokens.json"

DEVICE_AUTH_URL = f"{KEYCLOAK_URL}/realms/{REALM}/protocol/openid-connect/auth/device"
TOKEN_URL = f"{KEYCLOAK_URL}/realms/{REALM}/protocol/openid-connect/token"
USERINFO_URL = f"{KEYCLOAK_URL}/realms/{REALM}/protocol/openid-connect/userinfo"

def device_login():
    """Initiate device authorization flow."""
    # Step 1: Request device authorization
    response = requests.post(
        DEVICE_AUTH_URL,
        data={
            "client_id": CLIENT_ID,
            "scope": "openid profile email offline_access",
        },
    )

    if response.status_code != 200:
        print(f"Error requesting device authorization: {response.text}", file=sys.stderr)
        sys.exit(1)

    device_data = response.json()
    device_code = device_data["device_code"]
    user_code = device_data["user_code"]
    verification_uri = device_data["verification_uri"]
    verification_uri_complete = device_data.get("verification_uri_complete", "")
    expires_in = device_data["expires_in"]
    interval = device_data.get("interval", 5)

    # Step 2: Display the code
    print()
    print("  To sign in, open this URL in your browser:")
    print()
    print(f"    {verification_uri}")
    print()
    print(f"  Enter the code: {user_code}")
    print()

    # Try to open the browser automatically
    try:
        if verification_uri_complete:
            webbrowser.open(verification_uri_complete)
            print("  (Browser opened automatically)")
            print()
    except Exception:
        pass

    # Step 3: Poll for the token
    print("  Waiting for authentication...", end="", flush=True)
    start_time = time.time()

    while time.time() - start_time < expires_in:
        time.sleep(interval)

        token_response = requests.post(
            TOKEN_URL,
            data={
                "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
                "client_id": CLIENT_ID,
                "device_code": device_code,
            },
        )

        if token_response.status_code == 200:
            tokens = token_response.json()
            save_tokens(tokens)
            print(" Done!")
            print()
            print("  Successfully authenticated!")
            return tokens

        error_data = token_response.json()
        error = error_data.get("error")

        if error == "authorization_pending":
            print(".", end="", flush=True)
            continue
        elif error == "slow_down":
            interval += 5
            continue
        elif error == "access_denied":
            print()
            print("  Authorization denied by user.", file=sys.stderr)
            sys.exit(1)
        elif error == "expired_token":
            print()
            print("  Device code expired. Please try again.", file=sys.stderr)
            sys.exit(1)
        else:
            print()
            print(f"  Unexpected error: {error_data}", file=sys.stderr)
            sys.exit(1)

    print()
    print("  Timeout waiting for authentication.", file=sys.stderr)
    sys.exit(1)

def refresh_token():
    """Refresh the access token using the stored refresh token."""
    tokens = load_tokens()
    if not tokens or "refresh_token" not in tokens:
        return None

    response = requests.post(
        TOKEN_URL,
        data={
            "grant_type": "refresh_token",
            "client_id": CLIENT_ID,
            "refresh_token": tokens["refresh_token"],
        },
    )

    if response.status_code != 200:
        # Refresh token expired, need to re-authenticate
        return None

    new_tokens = response.json()
    save_tokens(new_tokens)
    return new_tokens

def get_valid_token():
    """Get a valid access token, refreshing or re-authenticating as needed."""
    tokens = load_tokens()

    if tokens:
        # Try using the existing access token
        test = requests.get(
            USERINFO_URL,
            headers={"Authorization": f"Bearer {tokens['access_token']}"},
        )
        if test.status_code == 200:
            return tokens["access_token"]

        # Access token expired, try refreshing
        new_tokens = refresh_token()
        if new_tokens:
            return new_tokens["access_token"]

    # No valid tokens, need to authenticate
    tokens = device_login()
    return tokens["access_token"]

def save_tokens(tokens):
    """Save tokens to disk."""
    TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
    TOKEN_FILE.write_text(json.dumps(tokens, indent=2))
    TOKEN_FILE.chmod(0o600)

def load_tokens():
    """Load tokens from disk."""
    if not TOKEN_FILE.exists():
        return None
    try:
        return json.loads(TOKEN_FILE.read_text())
    except (json.JSONDecodeError, OSError):
        return None

def whoami():
    """Show the current authenticated user."""
    token = get_valid_token()
    response = requests.get(
        USERINFO_URL,
        headers={"Authorization": f"Bearer {token}"},
    )
    if response.status_code == 200:
        user = response.json()
        print(f"Logged in as: {user.get('name', user.get('preferred_username'))}")
        print(f"Email: {user.get('email')}")
    else:
        print("Failed to get user info", file=sys.stderr)

def logout():
    """Remove stored tokens."""
    if TOKEN_FILE.exists():
        TOKEN_FILE.unlink()
    print("Logged out successfully.")

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: my-cli-tool <login|whoami|logout>")
        sys.exit(1)

    command = sys.argv[1]
    if command == "login":
        device_login()
    elif command == "whoami":
        whoami()
    elif command == "logout":
        logout()
    else:
        print(f"Unknown command: {command}")
        sys.exit(1)

Install the dependency and run it:

pip install requests

python my_cli_tool.py login
python my_cli_tool.py whoami
python my_cli_tool.py logout

You can verify the tokens returned by the device flow using the JWT Token Analyzer.

IoT Device Implementation Considerations

CLI tools run on developer machines with internet access and file systems. IoT devices have additional constraints.

Constrained Displays

For devices with small screens (e.g., a thermostat or printer), display the verification_uri_complete as a QR code instead of a text URL:

# Using the qrcode library
import qrcode

def show_qr_code(url):
    qr = qrcode.QRCode(version=1, box_size=1, border=1)
    qr.add_data(url)
    qr.make(fit=True)
    qr.print_ascii()

No Display at All

For headless IoT sensors, the device code and verification URL must be communicated through an alternative channel:

  • Send via MQTT to a management dashboard
  • Blink the user code on an LED (for very short codes)
  • Print to a connected receipt printer
  • Announce via a speaker/buzzer pattern

Token Storage on IoT

IoT devices should store tokens in secure storage:

  • Embedded Linux: Use a hardware security module (HSM) or the kernel keyring
  • Microcontrollers: Use flash encryption if available
  • Mobile IoT gateways: Use the platform’s secure keystore (Keychain on iOS, Keystore on Android)

For more on IoT and identity management, see our post on how IoT devices are revolutionizing identity management.

Offline Token for Long-Running Devices

IoT devices often run unattended for months. Use offline tokens (offline_access scope) to maintain authentication without requiring periodic user interaction:

curl -X POST "$DEVICE_AUTH_URL" 
  -d "client_id=my-iot-device" 
  -d "scope=openid offline_access"

Offline tokens survive Keycloak restarts and session cleanup. Configure their lifespan under Realm Settings > Tokens > Offline Session Idle and Offline Session Max Lifespan.

Security Considerations

User Code Entropy

Keycloak generates user codes in the format XXXX-XXXX using a character set that excludes ambiguous characters (0/O, 1/I/L). The code space is large enough to prevent guessing attacks within the code’s short lifespan.

Code Lifetime

Set the device code lifespan to the minimum reasonable duration for your use case. Ten minutes is generous for CLI tools. For IoT provisioning flows where the user might need time to access the management console, 15-20 minutes may be appropriate.

Rate Limiting

The slow_down response is your signal that you are polling too fast. Always respect the interval value and implement exponential backoff on slow_down responses.

Client Type

For CLI tools distributed to end users, use a public client (no client secret). The client secret cannot be kept confidential in a distributed application. For server-side IoT hubs that are under your control, use a confidential client with a client secret.

For broader security guidance, see the Skycloak security page and our post on Keycloak auditing best practices.

Comparison with Other Auth Flows for Constrained Devices

Flow Browser Required on Device User Interaction Best For
Device Authorization Grant No Enter code on separate device CLI, IoT, smart TV
Authorization Code + PKCE Yes Login in embedded browser Mobile apps, desktop apps
Client Credentials No None (service identity) M2M, no user involvement
Resource Owner Password No Username/password on device Legacy (not recommended)

The device flow is the only standardized approach for devices that cannot display a browser. For machine-to-machine scenarios without user involvement, see our guide on machine-to-machine authentication with Keycloak.

Testing with curl

Here is the complete device flow using only curl, useful for testing and debugging:

# Step 1: Request device authorization
DEVICE_RESPONSE=$(curl -s -X POST 
  "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/auth/device" 
  -d "client_id=my-cli-tool" 
  -d "scope=openid profile")

echo "$DEVICE_RESPONSE" | jq .

# Extract values
DEVICE_CODE=$(echo "$DEVICE_RESPONSE" | jq -r '.device_code')
USER_CODE=$(echo "$DEVICE_RESPONSE" | jq -r '.user_code')
VERIFICATION_URI=$(echo "$DEVICE_RESPONSE" | jq -r '.verification_uri')
INTERVAL=$(echo "$DEVICE_RESPONSE" | jq -r '.interval')

echo "Open $VERIFICATION_URI and enter code: $USER_CODE"

# Step 2: Poll for token (run this in a loop)
while true; do
  sleep "$INTERVAL"
  TOKEN_RESPONSE=$(curl -s -X POST 
    "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token" 
    -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" 
    -d "client_id=my-cli-tool" 
    -d "device_code=$DEVICE_CODE")

  ERROR=$(echo "$TOKEN_RESPONSE" | jq -r '.error // empty')

  if [ -z "$ERROR" ]; then
    echo "Success!"
    echo "$TOKEN_RESPONSE" | jq .
    break
  elif [ "$ERROR" = "authorization_pending" ]; then
    echo -n "."
  elif [ "$ERROR" = "slow_down" ]; then
    INTERVAL=$((INTERVAL + 5))
  else
    echo "Error: $ERROR"
    break
  fi
done

Wrapping Up

The device authorization grant fills an important gap in OAuth 2.0: authenticating users on devices that cannot display a browser. Keycloak’s implementation follows RFC 8628 precisely, making it interoperable with any standards-compliant client.

For CLI tools, the implementation is straightforward — a single HTTP request to start the flow, a polling loop, and secure token storage. For IoT devices, the same protocol works but requires more thought around display, storage, and offline scenarios.

If you are building IoT or CLI applications that need Keycloak authentication and want to skip the infrastructure management, Skycloak provides managed Keycloak hosting with high availability, automated backups, and flexible pricing that scales with your device fleet.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman