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/oauthThe 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
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.
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:**
| Field | Type | Required | Description |
|---|---|---|---|
client_id | string | Yes | Your OAuth application client ID |
scope | string | No | Space-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
}| Field | Description |
|---|---|
device_code | Opaque token the device uses to poll for access tokens. Keep this secret — never expose it to the user. |
user_code | Short human-readable code (XXXX-XXXX format) displayed to the user for verification. |
verification_uri | Do not use. Points to a Frontegg JSON API endpoint, not a web page. See note below. |
verification_uri_complete | Same as verification_uri, with user_code appended. Do not use. |
expires_in | Seconds until the device code expires (default: 900 / 15 minutes). |
interval | Minimum seconds to wait between polling requests (default: 5). |
Important: display your own verification page URL
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-DHLMOr as a QR code URL:
https://myapp.com/device-activate?user_code=BCKF-DHLMAfter receiving the response:
- Display
user_codeprominently on screen. - Direct users to your verification page URL (not
verification_uri). - Store
device_codelocally — you'll need it to poll for tokens. - Use
intervalas the minimum delay between polling requests.
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
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:
- Host the page at a URL such as
/device-activate. - Read
user_codefrom the URL query string (?user_code=BCKF-DHLM), or prompt the user to enter it manually. - Authenticate the user. If using the Frontegg SDK on a protected route, this happens automatically.
- Fetch device info to display the app name and requested scopes.
- Show a confirmation UI with the app name, scopes, and user code, then prompt the user to approve or deny.
- Submit the decision to Frontegg.
- Show a result screen telling the user to return to their device.
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/jsonResponse (200 OK):
{
"appName": "My TV App",
"scopes": "openid profile email",
"status": "pending"
}| Field | Description |
|---|---|
appName | Name of the application requesting access. |
scopes | Requested scopes, or undefined if none were requested. |
status | pending, approved, or denied. Only show the approve/deny UI when pending. |
POST /frontegg/oauth/device/verify
Authorization: Bearer {access_token}
Content-Type: application/jsonRequest body:
| Field | Type | Required | Description |
|---|---|---|---|
user_code | string | Yes | The code displayed on the device. |
approved | boolean | Yes | true 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.
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>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/jsonRequest body:
| Field | Type | Required | Description |
|---|---|---|---|
grant_type | string | Yes | urn:ietf:params:oauth:grant-type:device_code |
device_code | string | Yes | The device_code received in Step 1 |
client_id | string | Yes | The 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.
error | Meaning | Action |
|---|---|---|
authorization_pending | User hasn't acted yet | Wait interval seconds, then poll again |
slow_down | Polling too fast | Increase interval by 5 seconds, then retry |
access_denied | User denied the request | Stop polling — show a denied message |
expired_token | Device code has expired | Stop polling — restart the flow from the beginning |
invalid_grant | Client ID mismatch or code already used | Stop polling — the device code is invalid |
Error response format:
{
"error": "authorization_pending",
"errors": ["authorization_pending"],
"statusCode": 400
}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}`);
}
}
}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;
}The device authorization endpoint is advertised in the OIDC discovery document:
GET /frontegg/oauth/.well-known/openid-configurationLook for:
device_authorization_endpoint— the device authorization endpoint URL.grant_types_supported— should includeurn:ietf:params:oauth:grant-type:device_code.
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"
}'device_codeis secret. Never expose it to the user or include it in URLs. Onlyuser_codeis shown to the user.- Codes are short-lived. Device codes expire after
expires_inseconds (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_idused when polling must match the one used in the initial request. - Respect rate limits. Polling faster than
intervalreturnsslow_down. Back off by 5 seconds each time you receive this error.
| Scenario | HTTP status | Error |
|---|---|---|
Missing or empty client_id | 400 | Validation error |
Invalid client_id | 400 | invalid_client_id |
| User hasn't acted yet (polling) | 400 | authorization_pending |
| Polling too fast | 400 | slow_down |
| User denied | 400 | access_denied |
| Device code expired or not found | 400 | expired_token |
| Client ID mismatch on poll | 400 | invalid_grant |
Missing auth on GET /device | 401 | Unauthorized |
Missing auth on POST /device/verify | 401 | Unauthorized |
| OAuth not enabled for workspace | 404 | Feature not enabled |