Skip to content
Last updated

Device flow

The Device Authorization Grant (RFC 8628) lets users authenticate on input-constrained devices — smart TVs, CLI tools, IoT devices, and set-top boxes — without typing credentials directly on the device. The device displays a short code and a URL; the user opens the URL on a phone or laptop, signs in, and approves access. The device polls in the background and receives tokens once the user approves.


Prerequisites

Prerequisites

  • A Frontegg workspace with OAuth enabled.
  • An OAuth application configured in your Frontegg dashboard.
  • Your application ID.
  • Your Frontegg domain (e.g. https://app-xxxx.frontegg.com).

All API calls in this guide use your Frontegg domain as the base URL:
https://{your-domain}/frontegg/oauth

How it works

The device flow has three participants:

  • Device app (you build) — the TV, CLI, or IoT application that initiates the flow and polls for tokens.
  • Verification page (you build) — a page in your web application where the user signs in and approves or denies the request.
  • Frontegg APIs — the backend that manages device codes, user verification, and token issuance.
 Device App                       Frontegg APIs                Your Verification Page
      |                                |                                |
      |  1. POST /device/authorize     |                                |
      |------------------------------->|                                |
      |  { device_code, user_code,     |                                |
      |    verification_uri, ... }     |                                |
      |<-------------------------------|                                |
      |                                |                                |
      |  Display code + URL pointing   |                                |
      |  to YOUR verification page     |                                |
      |                                |                                |
      |                                |     2. User opens your page    |
      |                                |<-------------------------------|
      |                                |     3. User signs in           |
      |                                |<-------------------------------|
      |                                |     4. GET /device?user_code=  |
      |                                |<-------------------------------|
      |                                |     { appName, scopes, status }|
      |                                |------------------------------->|
      |                                |     5. POST /device/verify     |
      |                                |        { approved: true }      |
      |                                |<-------------------------------|
      |                                |                                |
      |  6. POST /token (polling)      |                                |
      |------------------------------->|                                |
      |  { access_token, id_token,     |                                |
      |    refresh_token }             |                                |
      |<-------------------------------|                                |

Important: you must build your own verification page

The verification_uri returned by the API is not a user-facing web page, it points to a Frontegg JSON API endpoint. Do not display it to users or encode it in a QR code.

Frontegg provides the backend APIs for the device flow but does not host a verification UI. You must build a verification page in your own application (see Step 2) and display its URL to the user instead.


Step 1: Request device authorization

The device initiates the flow by requesting a device code from the following public endpoint (no authentication required):

POST /frontegg/oauth/device/authorize
Content-Type: application/json

**Request body:**
FieldTypeRequiredDescription
client_idstringYesYour OAuth application client ID
scopestringNoSpace-separated list of scopes

Example:

curl -X POST https://{your-domain}/frontegg/oauth/device/authorize \
  -H "Content-Type: application/json" \
  -d '{"client_id": "your-client-id", "scope": "openid profile email"}'

**Response (200 OK):**
{
  "device_code": "a1b2c3d4e5f6...",
  "user_code": "BCKF-DHLM",
  "verification_uri": "https://app-xxxx.frontegg.com/oauth/device",
  "verification_uri_complete": "https://app-xxxx.frontegg.com/oauth/device?user_code=BCKF-DHLM",
  "expires_in": 1800,
  "interval": 5
}
FieldDescription
device_codeOpaque token the device uses to poll for access tokens. Keep this secret — never expose it to the user.
user_codeShort human-readable code (XXXX-XXXX format) displayed to the user for verification.
verification_uriDo not use. Points to a Frontegg JSON API endpoint, not a web page. See note below.
verification_uri_completeSame as verification_uri, with user_code appended. Do not use.
expires_inSeconds until the device code expires (default: 900 / 15 minutes).
intervalMinimum seconds to wait between polling requests (default: 5).

Important: display your own verification page URL

verification_uri and verification_uri_complete point to a Frontegg JSON API endpoint, not a user-facing page. Displaying them to users or encoding them in a QR code will not work.

Instead, direct users to your own verification page (see Step 2) and pass the user_code as a query parameter:

Go to: https://myapp.com/device-activate
Enter code: BCKF-DHLM

Or as a QR code URL:

https://myapp.com/device-activate?user_code=BCKF-DHLM

After receiving the response:
  • Display user_code prominently on screen.
  • Direct users to your verification page URL (not verification_uri).
  • Store device_code locally — you'll need it to poll for tokens.
  • Use interval as the minimum delay between polling requests.

Step 2: Build the verification page

Host a page in your application where authenticated users can review and approve or deny device access requests. This page calls the Frontegg device APIs on behalf of the signed-in user.

Using the Frontegg SDK

The verification page can be a protected route in your application using a Frontegg SDK (@frontegg/react, @frontegg/nextjs, @frontegg/angular, @frontegg/vue). When the route is behind the SDK's authentication guard, unauthenticated users are automatically redirected to login and back — no manual token handling required.


Your verification page should follow this flow:
  1. Host the page at a URL such as /device-activate.
  2. Read user_code from the URL query string (?user_code=BCKF-DHLM), or prompt the user to enter it manually.
  3. Authenticate the user. If using the Frontegg SDK on a protected route, this happens automatically.
  4. Fetch device info to display the app name and requested scopes.
  5. Show a confirmation UI with the app name, scopes, and user code, then prompt the user to approve or deny.
  6. Submit the decision to Frontegg.
  7. Show a result screen telling the user to return to their device.

Get device info

Retrieve details about the device request to display to the user before they approve.

GET /frontegg/oauth/device?user_code={user_code}
Authorization: Bearer {access_token}
Accept: application/json

Response (200 OK):

{
  "appName": "My TV App",
  "scopes": "openid profile email",
  "status": "pending"
}
FieldDescription
appNameName of the application requesting access.
scopesRequested scopes, or undefined if none were requested.
statuspending, approved, or denied. Only show the approve/deny UI when pending.

Approve or deny

POST /frontegg/oauth/device/verify
Authorization: Bearer {access_token}
Content-Type: application/json

Request body:

FieldTypeRequiredDescription
user_codestringYesThe code displayed on the device.
approvedbooleanYestrue to approve, false to deny.

Example:

curl -X POST https://{your-domain}/frontegg/oauth/device/verify \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJhbGciOi..." \
  -d '{"user_code": "BCKF-DHLM", "approved": true}'

Response: 200 OK with no body returned on success.

Complete verification page example

The following self-contained HTML/JS example demonstrates the full verification page flow. It includes a simple login form for illustration. In production, protect this route with the Frontegg SDK so authentication is handled automatically, and replace the manual login with the SDK's getAccessToken().

Replace BASE_URL with your Frontegg domain (e.g. https://app-xxxx.frontegg.com).

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Authorize Device</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
    background: #f5f5f5; color: #1a1a2e;
    min-height: 100vh; display: flex; align-items: center; justify-content: center;
    padding: 16px;
  }
  .card {
    background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08);
    padding: 32px 24px; max-width: 400px; width: 100%; text-align: center;
  }
  h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; color: #1a1a2e; }
  .subtitle { font-size: 14px; color: #6b7280; margin-bottom: 24px; line-height: 1.4; }
  .text { font-size: 14px; color: #4a4a4a; line-height: 1.5; margin-bottom: 16px; }
  .app-name { font-weight: 600; color: #1a1a2e; }
  .code-box {
    background: #f0f4ff; border-radius: 8px; padding: 16px; margin: 16px 0;
    display: flex; flex-direction: column; align-items: center;
  }
  .code-label { font-size: 12px; color: #6b7280; margin-bottom: 8px; }
  .code-value {
    font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
    font-size: 32px; font-weight: 700; letter-spacing: 4px; color: #1a1a2e;
  }
  .scopes { font-size: 13px; color: #6b7280; margin-bottom: 16px; }
  .scopes strong { color: #4a4a4a; }
  .btn-row { display: flex; gap: 12px; margin-top: 24px; }
  .btn {
    flex: 1; padding: 14px 16px; font-size: 15px; font-weight: 600;
    border-radius: 8px; border: none; cursor: pointer; transition: all 0.15s;
  }
  .btn:active { transform: scale(0.97); }
  .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
  .btn-deny { background: #fff; border: 1.5px solid #d1d5db; color: #374151; }
  .btn-deny:hover:not(:disabled) { background: #f9fafb; }
  .btn-approve { background: #4361ee; color: #fff; }
  .btn-approve:hover:not(:disabled) { background: #3651d4; }
  .form-group { text-align: left; margin-bottom: 16px; }
  .form-group label { display: block; font-size: 13px; color: #6b7280; margin-bottom: 4px; }
  .form-group input {
    width: 100%; padding: 10px 12px; font-size: 15px; border: 1.5px solid #d1d5db;
    border-radius: 8px; outline: none; transition: border-color 0.15s;
  }
  .form-group input:focus { border-color: #4361ee; }
  .btn-login { width: 100%; background: #4361ee; color: #fff; margin-top: 8px; }
  .btn-login:hover:not(:disabled) { background: #3651d4; }
  .status { font-size: 13px; margin-top: 12px; }
  .status.error { color: #ef4444; }
  .status.success { color: #22c55e; }
  .result-icon { font-size: 48px; margin-bottom: 12px; }
  .result-icon.approved { color: #22c55e; }
  .result-icon.denied { color: #6b7280; }
</style>
</head>
<body>

<div class="card" id="card">
  <!-- Loading -->
  <div id="state-loading">
    <h1>Authorize Device</h1>
    <p class="subtitle">Loading...</p>
  </div>

  <!-- Login -->
  <div id="state-login" style="display:none">
    <h1>Sign In</h1>
    <p class="subtitle">Sign in to authorize the device</p>
    <div class="code-box">
      <span class="code-label">Device code</span>
      <span class="code-value" id="login-code">----</span>
    </div>
    <form onsubmit="doLogin(event)">
      <div class="form-group">
        <label for="email">Email</label>
        <input type="email" id="email" placeholder="you@example.com" required autocomplete="username">
      </div>
      <div class="form-group">
        <label for="password">Password</label>
        <input type="password" id="password" required autocomplete="current-password">
      </div>
      <button type="submit" class="btn btn-login" id="btn-login">Sign In</button>
    </form>
    <div class="status" id="login-status"></div>
  </div>

  <!-- Confirm -->
  <div id="state-confirm" style="display:none">
    <h1>Authorize Device</h1>
    <p class="text"><span class="app-name" id="confirm-app">App</span> is requesting access to your account.</p>
    <div class="code-box">
      <span class="code-label">Confirm this code matches your device</span>
      <span class="code-value" id="confirm-code">----</span>
    </div>
    <div class="scopes" id="confirm-scopes"></div>
    <div class="btn-row">
      <button class="btn btn-deny" id="btn-deny" onclick="doVerify(false)">Deny</button>
      <button class="btn btn-approve" id="btn-approve" onclick="doVerify(true)">Approve</button>
    </div>
    <div class="status" id="confirm-status"></div>
  </div>

  <!-- Done -->
  <div id="state-done" style="display:none">
    <div class="result-icon" id="done-icon"></div>
    <h1 id="done-title"></h1>
    <p class="subtitle" id="done-subtitle"></p>
  </div>

  <!-- Error -->
  <div id="state-error" style="display:none">
    <h1>Something went wrong</h1>
    <p class="status error" id="error-msg"></p>
  </div>
</div>

<script>
const BASE_URL = 'https://app-xxxx.frontegg.com'; // Replace with your Frontegg domain

const params = new URLSearchParams(window.location.search);
const userCode = params.get('user_code') || '';

let token = null;

function show(state) {
  ['loading', 'login', 'confirm', 'done', 'error'].forEach(s => {
    document.getElementById('state-' + s).style.display = (s === state) ? 'block' : 'none';
  });
}

function showError(msg) {
  document.getElementById('error-msg').textContent = msg;
  show('error');
}

if (!userCode) {
  show('error');
  document.getElementById('error-msg').textContent =
    'No user code provided. Scan the QR code on your device to get started.';
} else {
  document.getElementById('login-code').textContent = userCode;
  show('login');
}

async function doLogin(e) {
  e.preventDefault();
  const email = document.getElementById('email').value.trim();
  const password = document.getElementById('password').value;
  const btn = document.getElementById('btn-login');
  const status = document.getElementById('login-status');

  btn.disabled = true;
  status.textContent = 'Signing in...';
  status.className = 'status';

  try {
    const res = await fetch(`${BASE_URL}/frontegg/identity/resources/auth/v1/user`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });
    if (!res.ok) {
      const text = await res.text();
      throw new Error(text || `Login failed (${res.status})`);
    }
    const data = await res.json();
    token = data.accessToken || data.token;
    if (!token) throw new Error('No token in login response');
    await loadDeviceInfo();
  } catch (err) {
    status.textContent = err.message;
    status.className = 'status error';
    btn.disabled = false;
  }
}

async function loadDeviceInfo() {
  try {
    const res = await fetch(
      `${BASE_URL}/frontegg/oauth/device?user_code=${encodeURIComponent(userCode)}`,
      { headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${token}` } }
    );
    if (!res.ok) {
      const text = await res.text();
      throw new Error(text || `Failed to load device info (${res.status})`);
    }
    const info = await res.json();

    if (info.status !== 'pending') {
      showError('This device code has already been used or has expired.');
      return;
    }

    document.getElementById('confirm-app').textContent = info.appName || 'An application';
    document.getElementById('confirm-code').textContent = userCode;
    if (info.scopes) {
      document.getElementById('confirm-scopes').innerHTML =
        '<strong>Permissions:</strong> ' + info.scopes;
    }
    show('confirm');
  } catch (err) {
    showError(err.message);
  }
}

async function doVerify(approved) {
  const btnApprove = document.getElementById('btn-approve');
  const btnDeny = document.getElementById('btn-deny');
  const status = document.getElementById('confirm-status');

  btnApprove.disabled = true;
  btnDeny.disabled = true;
  status.textContent = approved ? 'Approving...' : 'Denying...';
  status.className = 'status';

  try {
    const res = await fetch(`${BASE_URL}/frontegg/oauth/device/verify`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`,
      },
      body: JSON.stringify({ user_code: userCode, approved }),
    });
    if (!res.ok) {
      const text = await res.text();
      throw new Error(text || `Verification failed (${res.status})`);
    }

    const icon = document.getElementById('done-icon');
    const title = document.getElementById('done-title');
    const subtitle = document.getElementById('done-subtitle');

    if (approved) {
      icon.textContent = '✓';
      icon.className = 'result-icon approved';
      title.textContent = 'Device Authorized';
      subtitle.textContent = 'You can close this page and return to your device.';
    } else {
      icon.textContent = '✗';
      icon.className = 'result-icon denied';
      title.textContent = 'Device Denied';
      subtitle.textContent = 'The request was denied. You can close this page.';
    }
    show('done');
  } catch (err) {
    status.textContent = err.message;
    status.className = 'status error';
    btnApprove.disabled = false;
    btnDeny.disabled = false;
  }
}
</script>
</body>
</html>

Step 3: Poll for tokens (device side)

After displaying the code, the device polls the token endpoint until the user approves, denies, or the code expires. This is a public endpoint — no authentication required.

POST /frontegg/oauth/token
Content-Type: application/json

Request body:

FieldTypeRequiredDescription
grant_typestringYesurn:ietf:params:oauth:grant-type:device_code
device_codestringYesThe device_code received in Step 1
client_idstringYesThe same client_id used in Step 1

Example:

curl -X POST https://{your-domain}/frontegg/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
    "device_code": "a1b2c3d4e5f6...",
    "client_id": "your-client-id"
  }'

Success response (200 OK):

{
  "access_token": "eyJhbGciOi...",
  "id_token": "eyJhbGciOi...",
  "refresh_token": "550e8400-e29b-41d4-a716-446655440000",
  "token_type": "Bearer",
  "expires_in": 86400
}

Polling error responses (400):

While the user hasn't yet acted, the endpoint returns 400 with one of the following error values. Continue polling for authorization_pending and slow_down; stop for all others.

errorMeaningAction
authorization_pendingUser hasn't acted yetWait interval seconds, then poll again
slow_downPolling too fastIncrease interval by 5 seconds, then retry
access_deniedUser denied the requestStop polling — show a denied message
expired_tokenDevice code has expiredStop polling — restart the flow from the beginning
invalid_grantClient ID mismatch or code already usedStop polling — the device code is invalid

Error response format:

{
  "error": "authorization_pending",
  "errors": ["authorization_pending"],
  "statusCode": 400
}

Polling implementation

async function pollForTokens(deviceCode, clientId, intervalSeconds) {
  let interval = intervalSeconds * 1000;

  while (true) {
    await sleep(interval);

    const response = await fetch('https://{your-domain}/frontegg/oauth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
        device_code: deviceCode,
        client_id: clientId,
      }),
    });

    if (response.ok) {
      return await response.json(); // { access_token, id_token, refresh_token, ... }
    }

    const error = await response.json();

    switch (error.error) {
      case 'authorization_pending':
        continue;
      case 'slow_down':
        interval += 5000;
        continue;
      case 'access_denied':
        throw new Error('User denied the request');
      case 'expired_token':
        throw new Error('Device code expired — restart the flow');
      default:
        throw new Error(`Unexpected error: ${error.error}`);
    }
  }
}

Complete device-side example

The following example shows the full device-side flow — requesting a device code and polling for tokens. The verification page (Step 2) runs separately in your web application.

const FRONTEGG_DOMAIN = 'https://app-xxxx.frontegg.com';
const CLIENT_ID = 'your-client-id'; //Your applcation ID
const VERIFICATION_PAGE = 'https://myapp.com/device-activate';

async function deviceSignIn() {
  const authResponse = await fetch(`${FRONTEGG_DOMAIN}/frontegg/oauth/device/authorize`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ client_id: CLIENT_ID }),
  });
  const auth = await authResponse.json();

  // Direct users to your verification page — not to verification_uri
  console.log(`Go to: ${VERIFICATION_PAGE}`);
  console.log(`Enter code: ${auth.user_code}`);

  const tokens = await pollForTokens(auth.device_code, CLIENT_ID, auth.interval);

  console.log('Access token:', tokens.access_token);
  console.log('Refresh token:', tokens.refresh_token);

  return tokens;
}

OIDC discovery

The device authorization endpoint is advertised in the OIDC discovery document:

GET /frontegg/oauth/.well-known/openid-configuration

Look for:

  • device_authorization_endpoint — the device authorization endpoint URL.
  • grant_types_supported — should include urn:ietf:params:oauth:grant-type:device_code.

Refresh tokens

Use the refresh_token from the token response with the standard OAuth refresh flow:

curl -X POST https://{your-domain}/frontegg/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "refresh_token",
    "refresh_token": "550e8400-e29b-41d4-a716-446655440000",
    "client_id": "your-client-id"
  }'

Security notes

  • device_code is secret. Never expose it to the user or include it in URLs. Only user_code is shown to the user.
  • Codes are short-lived. Device codes expire after expires_in seconds (default: 30 minutes).
  • Single use. Once tokens are issued, the device code is invalidated. Any subsequent exchange attempt will fail.
  • Client ID binding. The client_id used when polling must match the one used in the initial request.
  • Respect rate limits. Polling faster than interval returns slow_down. Back off by 5 seconds each time you receive this error.

Error handling summary

ScenarioHTTP statusError
Missing or empty client_id400Validation error
Invalid client_id400invalid_client_id
User hasn't acted yet (polling)400authorization_pending
Polling too fast400slow_down
User denied400access_denied
Device code expired or not found400expired_token
Client ID mismatch on poll400invalid_grant
Missing auth on GET /device401Unauthorized
Missing auth on POST /device/verify401Unauthorized
OAuth not enabled for workspace404Feature not enabled