Keycloak OAuth Device Flow for IoT and CLI Apps
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:
-
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.
-
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).
-
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).
-
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.
-
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
- Navigate to your realm in the Keycloak Admin Console.
- Go to Clients and click Create client (or edit an existing client).
- Configure the client:
- Client ID:
my-cli-tool - Client Protocol:
openid-connect
- Client ID:
- 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
- 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:
- Go to Realm Settings > Tokens.
- 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.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.