Device Authorization Grant (RFC 8628)
Source:
en/oauth/device-flow.md· Live: https://docs.1pass.dev/en/oauth/device-flow LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).
📍 Jump-to Index
- L38-L65: ## Sequence diagram
- L66-L133: ## Endpoints
- L68-L105: ### 1. Device Authorization request
- L106-L133: ### 2. Token polling
- L134-L197: ## Polling error codes
- L148-L169: ### Polling cadence —
slow_down(HTTP 400) - L170-L197: ### Rate limit —
rate_limited(HTTP 429)
- L148-L169: ### Polling cadence —
- L198-L290: ## Code examples
- L200-L218: ### curl
- L219-L258: ### JavaScript (browser)
- L259-L290: ### Ruby (Rails RP backend)
- L291-L299: ## Security guide
- L300-L307: ## References
Device Authorization Grant (RFC 8628)
A device flow based on RFC 8628 — OAuth 2.0 Device Authorization Grant. The RP shows a QR on its own page and receives the token by polling. When the user scans the QR with the logi app and approves, the RP page automatically advances to the next step.
💡 Tip: When to use it
- When the user must not leave the RP page (the Naver pattern, just before payment, SSO inside a modal)
- Environments with no browser (CLI, Smart TV, IoT devices)
- In-app browsers where iOS Universal Links don't work (KakaoTalk / Naver in-app)
ℹ️ Note: Public clients can use the device flow too RFC 8628 §3.4 — both public and confidential clients support the device flow. A public RP can start device authorization with just
client_id, without aclient_secret.
If you are an ordinary web RP where page redirects are allowed, the standard Authorization Code Flow is simpler. If you don't need UI control and your goal is a fast integration, see the Widget SDK.
Sequence diagram
sequenceDiagram
autonumber
participant U as User
participant RP as RP page
participant L as logi server (api.1pass.dev)
participant App as logi mobile app
RP->>L: POST /oauth/device_authorization (client_id, scope)
L-->>RP: { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval }
RP->>RP: Render QR (verification_uri_complete) + start polling every interval seconds
loop polling (interval=5s recommended)
RP->>L: POST /oauth/token (grant_type=device_code, device_code)
L-->>RP: 400 { error: "authorization_pending" }
end
U->>App: Scan QR
App->>L: User authentication + scope consent
L->>L: Transition device_code → approved state
RP->>L: POST /oauth/token (grant_type=device_code, device_code)
L-->>RP: 200 { access_token, refresh_token, id_token?, token_type, expires_in }
RP->>U: Sign-in complete (no page navigation)Endpoints
1. Device Authorization request
POST https://api.1pass.dev/oauth/device_authorization
Content-Type: application/x-www-form-urlencoded| Parameter | Required | Description |
|---|---|---|
client_id | ✅ | The RP's OAuth Application client_id |
client_secret | confidential only | Required only for confidential clients (Basic auth or form). Not used by public clients — sending it is rejected (downgrade protection) |
scope | ✅ | A space-separated scope list (e.g. openid profile:basic email) |
Response (200 OK):
{
"device_code": "x7Y9aZ-...",
"user_code": "WDJB-MJHT",
"verification_uri": "https://api.1pass.dev/activate",
"verification_uri_complete": "https://api.1pass.dev/activate?user_code=WDJB-MJHT",
"expires_in": 600,
"interval": 5
}| Field | Meaning |
|---|---|
device_code | The secret token the RP uses for subsequent polling. Never expose it to the user |
user_code | A short code to display when the user needs to enter it manually (no need to show it when using a QR) |
verification_uri | A short URL the user visits directly in a browser |
verification_uri_complete | The full URL with user_code prefilled — use this value for QR encoding |
expires_in | The lifetime of the device_code in seconds. Default 600 seconds (10 minutes) |
interval | The minimum polling interval in seconds. Default 5 seconds. Add +5 seconds on a slow_down response |
⚠️ Warning: Cache headers The response automatically carries
Cache-Control: no-storeandPragma: no-cache(RFC 6749 §5.1).
2. Token polling
POST https://api.1pass.dev/oauth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)| Parameter | Required | Description |
|---|---|---|
grant_type | ✅ | Exactly urn:ietf:params:oauth:grant-type:device_code |
device_code | ✅ | The device_code received in step 1 |
client_id | ✅ | Include in the form body (when not using the Basic header) |
Success response (200 OK):
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "8aZk9...urlsafe_base64_32B_no_prefix...",
"scope": "openid profile:basic email"
}If scope includes openid, the response also returns an id_token. For token verification, see JWKS.
Polling error codes
Every device flow error is returned as 400 Bad Request or 401 Unauthorized, with a body of the form { "error": "...", "error_description": "..." }.
error | Meaning | RP's action |
|---|---|---|
authorization_pending | The user has not approved yet | Poll again after interval seconds |
slow_down | Polling too fast | Increase interval by +5 seconds and poll again |
access_denied | The user declined | Stop polling and inform the user. A new device flow must be started |
expired_token | device_code expired (past 10 minutes) | Stop polling and restart from /oauth/device_authorization |
invalid_grant | device_code is invalid / belongs to another client / already exchanged | Stop polling and start over |
invalid_client | client_id / client_secret authentication failed | Check your credentials |
unauthorized_client | The RP is not approved for the device flow | Check the application's approval status in the logi console |
Polling cadence — slow_down (HTTP 400)
This is the standard polling cadence control from RFC 8628 §3.5. If the RP polls /oauth/token faster than the interval it received, the logi server immediately returns slow_down on a per-device_code basis.
| Field | Value |
|---|---|
| HTTP status | 400 Bad Request |
error | slow_down |
error_description | polling too fast; respect the interval value |
Retry-After header | ❌ Not returned |
RP handling: per RFC 8628 §3.5, increase the current interval by a cumulative +5 seconds and wait until the next poll. Do not reset a value you have increased once within the same device flow.
// Handling slow_down inside the polling loop
const { error } = await res.json();
if (error === "slow_down") {
pollInterval += 5000; // cumulative increase, do not reset
continue;
}Rate limit — rate_limited (HTTP 429)
The logi server puts a controller-level rate limiter on both the /oauth/device_authorization and /oauth/token endpoints. If you exceed the limit (keyed by client_id, or by IP if absent), a 429 is returned, separate from the device_code polling cadence.
| Field | Value |
|---|---|
| HTTP status | 429 Too Many Requests |
error | rate_limited |
error_description | too many token requests (token endpoint) / too many device authorization requests (device_authorization endpoint) |
Retry-After header | ❌ Not currently set |
| Applied limit | /oauth/token 20 req/min, /oauth/device_authorization 30 req/min (both keyed by client_id or IP) |
RP handling: since there is no Retry-After header, back off with exponential backoff.
- First 429: wait for the current
interval× 2, then retry once. - If 429 repeats 3 or more times in a row, stop polling and inform the user (a signal to check the RP credentials/network).
- If you get a 429 at the
/oauth/device_authorizationstep, back off at least 60 seconds before starting a new device flow. Do not retry infinitely.
// Handling 429 inside the polling loop (no Retry-After → self backoff)
if (res.status === 429) {
pollInterval = Math.min(pollInterval * 2, 60_000);
consecutive429 += 1;
if (consecutive429 >= 3) throw new Error("rate_limited");
continue;
}Code examples
curl
# 1. Device authorization request
curl -X POST https://api.1pass.dev/oauth/device_authorization \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "scope=openid profile:basic email"
# → receive device_code, user_code, verification_uri_complete
# 2. (while the user scans the QR) poll
curl -X POST https://api.1pass.dev/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
-d "device_code=$DEVICE_CODE"
# → authorization_pending → ... → access_tokenJavaScript (browser)
async function deviceLogin(clientId) {
// 1. Device authorization request — a browser with no client secret
// typically goes through the RP backend (the following is an example):
const init = await fetch("/api/auth/1pass/device/start", { method: "POST" });
const { device_code, user_code, verification_uri_complete, interval, expires_in } = await init.json();
// 2. Render the QR code (verification_uri_complete)
renderQR(verification_uri_complete);
showUserCode(user_code); // fallback for users who can't scan the QR
// 3. Polling loop
const deadline = Date.now() + expires_in * 1000;
let pollInterval = interval * 1000;
while (Date.now() < deadline) {
await sleep(pollInterval);
const res = await fetch("/api/auth/1pass/device/poll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_code }),
});
if (res.ok) {
const { access_token, id_token } = await res.json();
return { access_token, id_token };
}
const { error } = await res.json();
if (error === "slow_down") pollInterval += 5000;
else if (error === "authorization_pending") continue;
else throw new Error(error); // access_denied / expired_token / ...
}
throw new Error("device_code expired");
}Ruby (Rails RP backend)
# 1. Device authorization request (RP backend → logi)
require "net/http"
require "json"
def start_device_flow
uri = URI("https://api.1pass.dev/oauth/device_authorization")
req = Net::HTTP::Post.new(uri)
req.basic_auth(ENV["LOGI_CLIENT_ID"], ENV["LOGI_CLIENT_SECRET"])
req.set_form_data(scope: "openid profile:basic email")
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
JSON.parse(res.body)
end
# 2. Polling (the RP endpoint the browser calls)
def poll_device_flow(device_code)
uri = URI("https://api.1pass.dev/oauth/token")
req = Net::HTTP::Post.new(uri)
req.basic_auth(ENV["LOGI_CLIENT_ID"], ENV["LOGI_CLIENT_SECRET"])
req.set_form_data(
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device_code
)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
{ status: res.code.to_i, body: JSON.parse(res.body) }
endSecurity guide
- ✅ HTTPS only. All device flow traffic runs only over TLS.
- ✅ Protect the client_secret (confidential). A confidential RP must keep its
client_secreton the RP backend only. Do not include it in a browser or a mobile app binary. - ✅ Public clients can use the device flow too (RFC 8628 §3.4). A public RP starts device authorization with just
client_id— if it sends aclient_secret, logi rejects it withinvalid_client(downgrade protection). - ✅ The
device_codeis secret. Do not display it to the user. Encode onlyverification_uri_completeinto the QR. - ✅ Respect the
interval. You must increase it by +5 seconds on aslow_downresponse to avoid hitting the rate limit (/oauth/device_authorizationis 30 req/min, keyed byclient_id/ IP). - ✅ There is no
statein the device flow. CSRF protection is guaranteed by the secrecy and single-use nature of thedevice_codeitself — the RP should bind its own user session to thedevice_codeon the server side.