Skip to content

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

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

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 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 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 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가 제품의 신뢰를 만듭니다.