Skip to content

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)
  • 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 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

mermaid
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
ParameterRequiredDescription
client_idThe RP's OAuth Application client_id
client_secretconfidential onlyRequired only for confidential clients (Basic auth or form). Not used by public clients — sending it is rejected (downgrade protection)
scopeA space-separated scope list (e.g. openid profile:basic email)

Response (200 OK):

json
{
  "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
}
FieldMeaning
device_codeThe secret token the RP uses for subsequent polling. Never expose it to the user
user_codeA short code to display when the user needs to enter it manually (no need to show it when using a QR)
verification_uriA short URL the user visits directly in a browser
verification_uri_completeThe full URL with user_code prefilled — use this value for QR encoding
expires_inThe lifetime of the device_code in seconds. Default 600 seconds (10 minutes)
intervalThe 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-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)
ParameterRequiredDescription
grant_typeExactly urn:ietf:params:oauth:grant-type:device_code
device_codeThe device_code received in step 1
client_idInclude in the form body (when not using the Basic header)

Success response (200 OK):

json
{
  "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": "..." }.

errorMeaningRP's action
authorization_pendingThe user has not approved yetPoll again after interval seconds
slow_downPolling too fastIncrease interval by +5 seconds and poll again
access_deniedThe user declinedStop polling and inform the user. A new device flow must be started
expired_tokendevice_code expired (past 10 minutes)Stop polling and restart from /oauth/device_authorization
invalid_grantdevice_code is invalid / belongs to another client / already exchangedStop polling and start over
invalid_clientclient_id / client_secret authentication failedCheck your credentials
unauthorized_clientThe RP is not approved for the device flowCheck 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.

FieldValue
HTTP status400 Bad Request
errorslow_down
error_descriptionpolling 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.

javascript
// 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.

FieldValue
HTTP status429 Too Many Requests
errorrate_limited
error_descriptiontoo 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.

  1. First 429: wait for the current interval × 2, then retry once.
  2. If 429 repeats 3 or more times in a row, stop polling and inform the user (a signal to check the RP credentials/network).
  3. If you get a 429 at the /oauth/device_authorization step, back off at least 60 seconds before starting a new device flow. Do not retry infinitely.
javascript
// 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

bash
# 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_token

JavaScript (browser)

javascript
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)

ruby
# 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) }
end

Security guide

  • HTTPS only. All device flow traffic runs only over TLS.
  • Protect the client_secret (confidential). A confidential RP must keep its client_secret on 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 a client_secret, logi rejects it with invalid_client (downgrade protection).
  • The device_code is secret. Do not display it to the user. Encode only verification_uri_complete into the QR.
  • Respect the interval. You must increase it by +5 seconds on a slow_down response to avoid hitting the rate limit (/oauth/device_authorization is 30 req/min, keyed by client_id / IP).
  • There is no state in the device flow. CSRF protection is guaranteed by the secrecy and single-use nature of the device_code itself — the RP should bind its own user session to the device_code on the server side.

References

최종 수정:

Identity가 제품의 신뢰를 만듭니다.