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.
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)
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 a client_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 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 |
Cache headers
The response automatically carries Cache-Control: no-store and Pragma: 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 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 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 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.