Keycloak Device Authorization Grant

Tested on Red Hat Enterprise Linux 10.2 (Coughlan)
Package podman-5.8.2-5.el10_2.x86_64
quay.io/keycloak/keycloak:26.7.3
Applies to Any Linux host with Podman or Docker
Privilege Normal user for rootless Podman; Keycloak admin access in the lab realm
Lab environment Keycloak 26.7.3 on http://127.0.0.1:8080/ — Install Keycloak with Podman
Scope Keycloak Device Authorization Grant (RFC 8628) on a public CLI client: device and user codes, browser verification, polling with authorization_pending and slow_down, terminal errors, and a UserInfo check. Does not cover Authorization Code or PKCE mechanics, Client Credentials, CIBA, refresh-token policy, or framework-specific CLI libraries.
Related guides Keycloak OpenID Connect tutorial
Keycloak PKCE tutorial
Keycloak clients explained
Keycloak public vs confidential clients
Access token vs ID token vs refresh token
Short on time?Beta Get the gist of this 12-minute read in seconds, then ask follow-up questions

A CLI running on a remote or headless system, smart TV, streaming device, or other client without a convenient local browser may still need a user to sign in. Keycloak Device Authorization Grant solves this by moving the interactive login to another browser while the original client waits for approval.

In this lab the CLI:

  • asks Keycloak for a device_code and a separate user_code
  • shows the operator a message such as “Open this URL and enter ABCD-EFGH”
  • polls Keycloak until the user finishes in the browser
  • receives access and refresh tokens only after approval

Realm golinuxcloud, public client device-lab-cli, and user tokenuser on Keycloak 26.7.3 at http://127.0.0.1:8080.


How Keycloak Device Authorization Grant works

Keycloak implements RFC 8628 for internet-connected devices with limited input or no suitable browser:

  • The user never types a password on the CLI or TV
  • Authentication and consent happen on a phone or laptop
  • The device only polls with the device_code it received earlier
  Device / CLI                         Browser / Phone
       |                                    |
       | asks Keycloak for device code      |
       v                                    |
    Keycloak                                |
       |                                    |
       | user_code + verification_uri       |
       v                                    |
  Device shows:                             |
  "Open URL and enter ABCD-EFGH"            |
                                            |
                           user opens URL --+
                                    |
                                    v
                                 Keycloak
                                    |
                             user signs in
                             and approves
                                    |
                                    v
  Device keeps polling Keycloak
            |
            | approval complete
            v
      access / refresh tokens

Keycloak returns two different codes. Do not call the device_code an authorization code — RFC 8628 treats them as separate protocol values.

Value Who uses it? Purpose
device_code CLI or device Long device-side code the CLI keeps and sends to Keycloak while polling; the user does not type it
user_code Human user Short code the user types at the verification page
verification_uri Human user Page where the user enters the user code
verification_uri_complete Human or browser Optional URL with the user code already embedded (link or QR-style handoff)
expires_in Device Seconds until this device authorization request expires
interval Device Minimum seconds to wait between token polling requests

Two rules keep the codes separate:

  • user_code — intentionally short enough for a person to type
  • device_code — opaque client-side credential for the pending transaction; not the code the user enters

Device Authorization Grant vs Authorization Code + PKCE

A beginner may ask why the CLI does not use PKCE. The difference is interaction capability, not public-versus-confidential client type alone.

Authorization Code + PKCE Device Authorization Grant
App can redirect or open a suitable browser on the same device Remote/headless CLI, TV, console, or device without a suitable local browser
Browser returns to the app callback URI User authenticates on another browser or device
App receives an authorization code and exchanges it Device receives a device_code and polls the token endpoint
Typical web or mobile login When local browser redirect is impractical

When the application can open a browser and receive the authorization response locally, Authorization Code flow with PKCE is usually the better fit. Device flow is useful when that interaction is impractical — see the Keycloak PKCE tutorial for the redirect-based path.


Configure Device Authorization Grant in Keycloak

Create public client device-lab-cli in realm golinuxcloud. If you need realm or user setup first, use Create and manage Keycloak users.

Device Authorization Grant is a separate client capability. For this lab:

  • Leave Standard flow and Direct access grants off
  • Leave Client authentication off so the client stays public
  • Send client_id only at the device and token endpoints — no client secret (RFC 8628 public-client behavior)

On Settings → Capability config, enable OAuth 2.0 Device Authorization Grant:

Keycloak public client with OAuth 2.0 Device Authorization Grant enabled

When you create the client through the Admin REST API, set oauth2.device.authorization.grant.enabled to "true" in client attributes. Keycloak does not accept a top-level oauth2DeviceAuthorizationGrantEnabled field on create.

This lab leaves Consent required off. That setting controls whether Keycloak requires general client consent, but consent-enabled client scopes can still cause Keycloak to show scope-specific approval items. That is why this lab still displays the Grant Access to Device Lab CLI page shown later.

Realm defaults on this host:

  • OAuth 2.0 Device Code Lifespan: 600 seconds
  • OAuth 2.0 Device Polling Interval: 5 seconds (advertised as interval in the device response)

Both live under Realm settings → Sessions. For the expired_token test later, I temporarily set device code lifespan to 30 seconds and restored 600 afterward.


Request a Keycloak device code and user code

The device authorization endpoint is:

text
POST /realms/{realm}/protocol/openid-connect/auth/device

Set the realm base URL once:

bash
export KC_BASE='http://127.0.0.1:8080/realms/golinuxcloud'

Request codes once for the public client and save the JSON. Include scope when you need OpenID claims in the token — the rest of this walkthrough uses the same transaction:

bash
DEVICE_RESP=$(curl -s -X POST "${KC_BASE}/protocol/openid-connect/auth/device" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'client_id=device-lab-cli' \
  -d 'scope=openid profile')

Pretty-print the response so you can read the fields:

bash
echo "$DEVICE_RESP" | python3 -m json.tool

Sample output:

output
{
    "device_code": "DDLRLgqHfk8i4OH0vpPKwVjOkp64cjwZF5oCO-QAW3I",
    "user_code": "QOJQ-DMYB",
    "verification_uri": "http://127.0.0.1:8080/realms/golinuxcloud/device",
    "verification_uri_complete": "http://127.0.0.1:8080/realms/golinuxcloud/device?user_code=QOJQ-DMYB",
    "expires_in": 600,
    "interval": 5
}

The two URI fields serve different handoffs:

  • verification_uri — user opens a browser and types user_code manually
  • verification_uri_complete — code embedded in the query string for a clickable link or QR-style handoff (RFC 8628 non-textual transmission)

Still show the user_code in the CLI so the operator can confirm they are approving the right request.

Extract the device-side secret the poller needs:

bash
DEVICE_CODE=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["device_code"])' <<<"$DEVICE_RESP")

Pull the human-readable code for the browser step:

bash
USER_CODE=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["user_code"])' <<<"$DEVICE_RESP")

Keycloak also returns the minimum poll spacing:

bash
POLL_INTERVAL=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["interval"])' <<<"$DEVICE_RESP")

Tell the operator what to do next:

bash
printf 'Open %s and enter code: %s\n' \
  'http://127.0.0.1:8080/realms/golinuxcloud/device' "$USER_CODE"

Poll before browser approval

Polling is how the CLI learns that browser authorization finished. Start before anyone opens the verification URI — that proves why polling exists.

The token request uses the RFC 8628 grant type:

text
grant_type=urn:ietf:params:oauth:grant-type:device_code
device_code=<value from /auth/device>
client_id=device-lab-cli

Poll once without opening the browser:

bash
curl -s -X POST "${KC_BASE}/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=device-lab-cli' \
  -d "device_code=${DEVICE_CODE}"

Sample output:

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

Nothing is wrong. Keycloak is telling the CLI that the user has not completed browser authorization yet. Wait at least interval seconds (five in this lab) before the next poll. RFC 8628 requires clients to respect that minimum spacing.


Authorize the device in a browser

Open verification_uri on a phone or laptop — or use verification_uri_complete if you want the code prefilled. Sign in as tokenuser with password TokenUser!2026. Keycloak 26.7.3 uses a two-step login form (username, then password).

After Keycloak authenticates the user, it may ask them to approve the scopes requested by device-lab-cli. In this lab the page lists:

  • User roles
  • User profile
  • Email address

Keycloak Device Authorization Grant consent screen for Device Lab CLI

The CLI never receives the user's password. Authentication and consent happen in the browser directly with Keycloak; the CLI only learns whether authorization eventually succeeded.

The consent buttons map directly to polling outcomes:

  • Yes — authorizes the pending device request; the next successful poll returns tokens
  • No — the next poll returns access_denied
  • Success — browser shows Device Login Successful with no redirect back to the CLI

Poll Keycloak until authorization completes

While the user works in the browser, the device keeps polling. Several responses are expected while the user is still completing browser authorization:

  Device starts polling
        |
        v
  authorization_pending
        |
        | wait interval
        v
   poll again
        |
        +--> authorization_pending -> keep waiting
        |
        +--> slow_down -> add 5 seconds to interval
        |
        +--> access_denied -> stop
        |
        +--> expired_token -> stop
        |
        +--> tokens -> success

RFC 8628 polling rules in short:

  • Wait at least interval seconds between polls
  • On slow_down, add five seconds to the interval for all later requests
  • access_denied and expired_token end polling — start a new device authorization

A standards-correct loop:

bash
while true; do
  sleep "$POLL_INTERVAL"
  TOKEN_BODY=$(curl -s -X POST "${KC_BASE}/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=device-lab-cli' \
    -d "device_code=${DEVICE_CODE}")
  ERROR=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("error",""))' "$TOKEN_BODY")
  if [ -z "$ERROR" ]; then
    echo "$TOKEN_BODY"
    break
  fi
  case "$ERROR" in
    authorization_pending) continue ;;
    slow_down) POLL_INTERVAL=$((POLL_INTERVAL + 5)); continue ;;
    *) echo "$TOKEN_BODY"; exit 1 ;;
  esac
done

A production client should also back off on network timeouts rather than retrying immediately. RFC 8628 recommends reducing polling frequency — for example with exponential backoff — when communication errors occur.

Run the loop after browser approval. A successful poll returns tokens similar to:

output
{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJUMk9SQ0dkbnd0Wl96UWpsYXY2WUc1S2h2a29KMndCdnFzWFlyc0FlcEhRIn0...","expires_in":300,"refresh_expires_in":1800,"refresh_token":"eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhMzEyMzQ4Mi1iODdiLTRjZTUtOTVjMy0zYjkxZGMwMGZjMzYifQ...","token_type":"Bearer","id_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJUMk9SQ0dkbnd0Wl96UWpsYXY2WUc1S2h2a29KMndCdnFzWFlyc0FlcEhRIn0...","scope":"openid profile email"}

The returned scope can include default client scopes configured on the Keycloak client in addition to scopes you list in the request. In this lab, email is attached as a default scope on device-lab-cli.

slow_down

If you poll faster than interval, Keycloak returns slow_down. Start a new device authorization request with the command above and update DEVICE_CODE from the new response. Before approving it in the browser, poll twice back-to-back:

bash
curl -s -X POST "${KC_BASE}/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=device-lab-cli' \
  -d "device_code=${DEVICE_CODE}"

The first response is authorization_pending. The immediate second poll on the same code returns:

output
{"error":"slow_down","error_description":"Slow down"}

Add five seconds to POLL_INTERVAL before the next attempt, as the loop above does.

access_denied

After the user clicks No on the consent screen, poll once more:

bash
curl -s -X POST "${KC_BASE}/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=device-lab-cli' \
  -d "device_code=${DEVICE_CODE}"

Sample output:

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

Start a new device authorization request; the old device_code will not succeed.

expired_token

If nobody approves before expires_in elapses, polling ends with expired_token. I shortened device code lifespan to 30 seconds in realm settings, waited 35 seconds without approving, then polled:

bash
curl -s -X POST "${KC_BASE}/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=device-lab-cli' \
  -d "device_code=${DEVICE_CODE}"

Keycloak answered:

output
{"error":"expired_token","error_description":"Device code is expired"}

Verify the access token with UserInfo

Once polling succeeds, the device client holds an access token. To confirm it represents the user who approved the device, call Keycloak's UserInfo endpoint with Authorization: Bearer.

Parse the token from the final poll response:

bash
ACCESS_TOKEN=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["access_token"])' "$TOKEN_BODY")

Request the approved identity:

bash
curl -s "${KC_BASE}/protocol/openid-connect/userinfo" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"

Sample output:

output
{"sub":"3c650623-fa6d-4252-9886-b5d5c093e7f5","email_verified":true,"name":"Token User","preferred_username":"tokenuser","given_name":"Token","family_name":"User","email":"tokenuser@golinuxcloud.lab"}

The preferred_username value confirms tokenuser approved the device. Deeper JWT validation, audience mappers, and API resource-server checks belong in the Keycloak OpenID Connect tutorial.


Troubleshoot Keycloak device flow

Response Meaning Client action
authorization_pending User has not approved yet Wait interval seconds; poll again
slow_down Polling too quickly Add 5 seconds to interval; poll again
access_denied User rejected the request Stop; start a new device authorization
expired_token Device code expired Stop; start a new device authorization
invalid_client Wrong client_id or client misconfiguration Check client ID, enabled flag, and device grant setting
Symptom Likely cause Fix
Client not allowed for OAuth device authorization grant Device grant disabled Enable OAuth 2.0 Device Authorization Grant on the client
Endless authorization_pending Browser step never finished Open verification URI; sign in; click Yes
No consent page like the screenshot Scopes not requested Add scope=openid profile on /auth/device
Tokens missing email or profile Scope omitted Include the scopes you need in the device request

Security considerations

  • Treat device_code as a client-side credential for the pending transaction; do not expose it in operator-facing output or logs. Display the human-readable user_code instead
  • Ask the user to confirm the code on screen matches the CLI before they click Yes
  • Do not silently open verification_uri_complete without showing the user code — phishing becomes easier when the operator cannot verify the session
  • Obey interval and back off on slow_down; tight polling wastes resources and may annoy operators
  • Use HTTPS and a trusted hostname for verification_uri in production; http://127.0.0.1:8080 is lab-only

Store refresh tokens from CLI tools in OS secure storage when you need long-lived access — not world-readable files.


Summary

Keycloak Device Authorization Grant fits remote or headless CLIs and devices that cannot conveniently complete a local browser redirect. The device receives a device_code and shows the user a short user_code with a verification URI; the user signs in and approves on another browser while the CLI polls with grant_type=urn:ietf:params:oauth:grant-type:device_code.

Polling behavior is central to the flow. authorization_pending is normal before approval; slow_down means wait longer; access_denied and expired_token mean stop and start over. The device_code is not an authorization code — it is a separate RFC 8628 value used only during polling.

For browser-based login on the same device, use Authorization Code flow with PKCE instead. For token lifetime details after device login, see Keycloak refresh tokens.


References


Frequently Asked Questions

Is device flow for SPAs?

Usually not. A normal SPA already runs in a suitable browser and should generally use Authorization Code flow with PKCE. Device Authorization Grant is intended for clients where completing the browser login locally is impractical, such as remote or headless CLIs, TVs, consoles, or devices without a suitable browser.

What is the user code?

The user code is a short, human-readable value Keycloak returns alongside the device_code. The operator enters it at the verification URI, or confirms it matches a prefilled URL, so they know which CLI or device they are approving.

How often should the client poll?

Wait at least the interval value from the /auth/device response — five seconds in this lab. Poll again after each authorization_pending. When Keycloak returns slow_down, add five seconds to your wait time before the next attempt.

Why am I getting authorization_pending?

That response means the user has not finished browser authorization yet. It is expected while polling. Keep waiting until you receive tokens or a terminal error such as access_denied or expired_token.

Can the device open the browser automatically?

You can hand the user verification_uri_complete as a clickable link, but still show the user_code in the CLI. Without that check, the user cannot verify they are approving the correct session.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)