Device Authorization Flow
The Device Authorization Grant (RFC 8628) lets CLI tools, IoT devices, and headless environments authenticate a user without opening a browser on the device itself. The user completes the browser step on any other device (phone, laptop, etc.).
When to Use This Flow
- You are building a CLI tool (
my-tool login)
- Your environment is headless — remote server over SSH, Docker container, CI runner
- Opening a browser programmatically is impossible or inconvenient
The client is always public (no client_secret). Use code_challenge only if you’re doing the (rarer) PKCE-for-device variant — Signet does not require it here.
How It Works
Step 1: Request a Device Code
curl -X POST https://your-signet/oauth/device/code \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID" \
-d "scope=openid profile email offline_access"
| Parameter | Required | Notes |
|---|---|---|
client_id |
yes | Public client with Device Flow enabled |
scope |
no | Space-separated; must be a subset of the client’s registered scopes. Omitted ⇒ defaults to the client’s full registered scope set. Include openid for an ID token |
resource |
no | RFC 8707 Resource Indicator. Absolute http(s) URI, no fragment, ≤ 1024 chars; repeat for multiple resources, max 10. When supplied, the issued access token’s aud is bound to these values — but each must be on your client’s allowed-resources allowlist (deny-all by default). Malformed or not-allowlisted values return invalid_target — see Errors |
Example with resource indicators (MCP / multi-RS):
curl -X POST https://your-signet/oauth/device/code \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID" \
-d "scope=read" \
-d "resource=https://api.example.com" \
-d "resource=https://mcp.example.com"
When resource is present, the user sees a dedicated device confirm consent page listing both resources and must click Confirm and Authorize before the device code is marked authorized. The resulting access token’s aud is bound to the requested resources.
The consent recorded at /device/verify is stored per resource set: approving the same app for a different resource combination creates an independent grant instead of overwriting the earlier one, each grant is revocable on its own under Account → Authorizations, and revoking one only invalidates the tokens issued under it. (Earlier releases kept a single grant per app.)
Response:
{
"device_code": "abc123...",
"user_code": "WXYZ-1234",
"verification_uri": "https://your-signet/device",
"expires_in": 1800,
"interval": 5
}
intervalis the minimum poll interval. Respectslow_down(see below) to back off further.
Step 2: Display Instructions to the User
To sign in, visit:
https://your-signet/device
And enter the code:
WXYZ-1234
Waiting for authorization…
If a browser is available locally, open verification_uri automatically (but still print the URL and code in case auto-open fails):
// Go
_ = exec.Command("open", verificationURI).Start() // macOS
_ = exec.Command("xdg-open", verificationURI).Start() // Linux
_ = exec.Command("cmd", "/c", "start", verificationURI).Start() // Windows
A QR code of verification_uri (plus displaying the code) is a friendly touch for mobile users.
Step 3: Poll for the Token
curl -X POST https://your-signet/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
-d "device_code=abc123..." \
-d "client_id=YOUR_CLIENT_ID"
Narrowing
resource(RFC 8707 §2.2): optionally passresource=...here to bind the access token to a subset of what was granted at/oauth/device/code. Widening (passing a resource that wasn’t in the original grant) returns400 invalid_target— the device code is not consumed, so the CLI may retry with a corrected list.
Success (user approved):
{
"access_token": "eyJhbG...",
"refresh_token": "def502...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email offline_access"
}
Errors while polling (HTTP 400, shape {"error": "...", "error_description": "..."}):
error |
HTTP | Meaning / Action |
|---|---|---|
authorization_pending |
400 | User hasn’t approved yet — keep polling at interval |
slow_down |
400 | Polling too fast — increase interval by ≥ 5 seconds |
access_denied |
400 | User rejected the request — stop polling |
expired_token |
400 | device_code past expires_in — restart from Step 1 |
invalid_grant |
400 | device_code unknown or already used — restart from Step 1 |
invalid_target |
400 | resource= on this token request is not a subset of the original device-code grant, or fails RFC 8707 shape validation. Device code is not consumed — retry with a corrected list. See Errors §Resource Indicator Errors |
429 Too Many Requests is also possible — see Tokens & Revocation §Rate Limits. The full error catalog lives in Errors.
Step 4: Use the Access Token
curl -H "Authorization: Bearer ACCESS_TOKEN" https://api.example.com/resource
Step 5: Refresh the Access Token
When the access token nears expiry, trade the refresh token — see Tokens & Revocation §Refreshing Tokens. Read the rotation-mode reuse-detection gotcha before implementing retries.
Step 6: Sign Out
On my-tool logout, revoke the refresh token — deleting the local token file alone leaves a stolen token valid until expiry. See Tokens & Revocation §Sign Out.
Storing Tokens Locally
CLI conventions:
- macOS: Keychain (e.g.,
security add-generic-password)
- Linux: Secret Service (libsecret) or file in
$XDG_CONFIG_HOME/<app>/token.jsonwith0600
- Windows: Credential Manager
Never write refresh tokens to log output or debug traces.
Integration Checklist
- [ ]
client_idwith Device Flow enabled by the admin
- [ ] Respect
intervaland back off onslow_down
- [ ] Restart flow on
expired_token/access_denied
- [ ] Store access & refresh tokens in OS-level secure storage
- [ ] Revoke the refresh token on logout
- [ ] Handle 429 rate-limit responses with backoff
Example CLI Client
github.com/go-signet/device-cli — complete Device Flow in Go.