docs/content/docs/plugins/device-authorization.mdx
RFC 8628 CLI Smart TV IoT
The Device Authorization plugin implements the OAuth 2.0 Device Authorization Grant (RFC 8628), enabling authentication for devices with limited input capabilities such as smart TVs, CLI applications, IoT devices, and gaming consoles.
You can test the device authorization flow right now using the Better Auth CLI:
npx auth login
This will demonstrate the complete device authorization flow by:
Add the device authorization plugin to your server configuration.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { deviceAuthorization } from "better-auth/plugins"; // [!code highlight]
export const auth = betterAuth({
// ... other config
plugins: [
deviceAuthorization({ // [!code highlight]
verificationUri: "/device", // [!code highlight]
}), // [!code highlight]
],
});
```
Run the migration or generate the schema to add the necessary tables to the database.
<Tabs items={["migrate", "generate"]}>
<Tab value="migrate">
```package-install
npx auth migrate
```
</Tab>
<Tab value="generate">
```package-install
npx auth generate
```
</Tab>
</Tabs>
See the [Schema](#schema) section to add the fields manually.
Add the device authorization plugin to your client.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins"; // [!code highlight]
export const authClient = createAuthClient({
plugins: [
deviceAuthorizationClient(), // [!code highlight]
],
});
```
The device flow follows these steps:
To initiate device authorization, call device.code with the client ID:
Example usage:
import { authClient } from "@/lib/auth-client"
const { data } = await authClient.device.code({
client_id: "your-client-id",
scope: "openid profile email",
});
if (data) {
console.log(`User code: ${data.user_code}`);
console.log(`Verification URL: ${data.verification_uri}`);
console.log(`Complete verification URL: ${data.verification_uri_complete}`);
}
After displaying the user code, poll for the access token:
<APIMethod path="/device/token" method="POST"> ```ts type deviceToken = { /** * Must be "urn:ietf:params:oauth:grant-type:device_code" */ grant_type: string; /** * The device code from the initial request */ device_code: string; /** * The OAuth client identifier */ client_id: string; } ``` </APIMethod>Example polling implementation:
let pollingInterval = 5; // Start with 5 seconds
const pollForToken = async () => {
const { data, error } = await authClient.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code,
client_id: yourClientId,
fetchOptions: {
headers: {
"user-agent": `My CLI`,
},
},
});
if (data?.access_token) {
console.log("Authorization successful!");
} else if (error) {
switch (error.error) {
case "authorization_pending":
// Continue polling
break;
case "slow_down":
pollingInterval += 5;
break;
case "access_denied":
console.error("Access was denied by the user");
return;
case "expired_token":
console.error("The device code has expired. Please try again.");
return;
default:
console.error(`Error: ${error.error_description}`);
return;
}
setTimeout(pollForToken, pollingInterval * 1000);
}
};
pollForToken();
The user authorization flow requires two steps:
GET /device. The verification request claims the pending device code for the calling session.Create a page where users can enter their code:
export default function DeviceAuthorizationPage() {
const { data: session } = authClient.useSession();
const searchParams = useSearchParams();
const [userCode, setUserCode] = useState(searchParams.get("user_code") || "");
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
try {
// Format the code: remove dashes and convert to uppercase
const formattedCode = userCode.trim().replace(/-/g, "").toUpperCase();
const approvalPath = `/device/approve?user_code=${encodeURIComponent(formattedCode)}`;
if (!session?.user) {
const verificationPath = `/device?user_code=${encodeURIComponent(formattedCode)}`;
window.location.href = `/login?redirect=${encodeURIComponent(verificationPath)}`;
return;
}
// Check if the code is valid using GET /device endpoint
const response = await authClient.device({
query: { user_code: formattedCode },
});
if (response.data) {
// Redirect to approval page
window.location.href = approvalPath;
}
} catch (err) {
setError("Invalid or expired code");
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={userCode}
onChange={(e) => setUserCode(e.target.value)}
placeholder="Enter device code (e.g., ABCD-1234)"
maxLength={12}
/>
<button type="submit">Continue</button>
{error && <p>{error}</p>}
</form>
);
}
Users must be authenticated to approve or deny device authorization requests:
export default function DeviceApprovalPage() {
const { user } = useAuth(); // Must be authenticated
const searchParams = useSearchParams();
const userCode = searchParams.get("user_code");
const [isProcessing, setIsProcessing] = useState(false);
const handleApprove = async () => {
setIsProcessing(true);
try {
await authClient.device.approve({
userCode: userCode,
});
// Show success message
alert("Device approved successfully!");
window.location.href = "/";
} catch (error) {
alert("Failed to approve device");
}
setIsProcessing(false);
};
const handleDeny = async () => {
setIsProcessing(true);
try {
await authClient.device.deny({
userCode: userCode,
});
alert("Device denied");
window.location.href = "/";
} catch (error) {
alert("Failed to deny device");
}
setIsProcessing(false);
};
if (!user) {
// Redirect to login if not authenticated
const verificationPath = `/device?user_code=${encodeURIComponent(userCode || "")}`;
window.location.href = `/login?redirect=${encodeURIComponent(verificationPath)}`;
return null;
}
return (
<div>
<h2>Device Authorization Request</h2>
<p>A device is requesting access to your account.</p>
<p>Code: {userCode}</p>
<button onClick={handleApprove} disabled={isProcessing}>
Approve
</button>
<button onClick={handleDeny} disabled={isProcessing}>
Deny
</button>
</div>
);
}
You can validate client IDs to ensure only authorized applications can use the device flow:
deviceAuthorization({
validateClient: async (clientId) => {
// Check if client is authorized
const client = await db.oauth_clients.findOne({ id: clientId });
return client && client.allowDeviceFlow;
},
onDeviceAuthRequest: async (clientId, scope) => {
// Log device authorization requests
await logDeviceAuthRequest(clientId, scope);
},
})
Customize how device and user codes are generated:
deviceAuthorization({
generateDeviceCode: async () => {
// Custom device code generation
return crypto.randomBytes(32).toString("hex");
},
generateUserCode: async () => {
// Custom user code generation
// Default uses: ABCDEFGHJKLMNPQRSTUVWXYZ23456789
// (excludes 0, O, 1, I to avoid confusion)
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 8; i++) {
code += charset[Math.floor(Math.random() * charset.length)];
}
return code;
},
})
The device flow defines specific error codes:
| Error Code | Description |
|---|---|
authorization_pending | User hasn't approved yet (continue polling) |
slow_down | Polling too frequently (increase interval) |
expired_token | Device code has expired |
access_denied | User denied the authorization |
invalid_grant | Invalid device code or client ID |
Here's a complete example for a CLI application based on the actual demo:
<Callout type="info"> To use the access token for API requests, ensure you have added the [Bearer plugin](/docs/plugins/bearer) to your auth instance. </Callout>import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins";
import open from "open";
const authClient = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [deviceAuthorizationClient()],
});
async function authenticateCLI() {
console.log("š Better Auth Device Authorization Demo");
console.log("ā³ Requesting device authorization...");
try {
// Request device code
const { data, error } = await authClient.device.code({
client_id: "demo-cli",
scope: "openid profile email",
});
if (error || !data) {
console.error("ā Error:", error?.error_description);
process.exit(1);
}
const {
device_code,
user_code,
verification_uri,
verification_uri_complete,
interval = 5,
} = data;
console.log("\nš± Device Authorization in Progress");
console.log(`Please visit: ${verification_uri}`);
console.log(`Enter code: ${user_code}\n`);
// Open browser to verification page
const urlToOpen = verification_uri_complete || verification_uri;
console.log("š Opening browser...");
await open(urlToOpen);
console.log(`ā³ Waiting for authorization... (polling every ${interval}s)`);
// Poll for token
await pollForToken(device_code, interval);
} catch (err) {
console.error("ā Error:", err.message);
process.exit(1);
}
}
async function pollForToken(deviceCode: string, interval: number) {
let pollingInterval = interval;
return new Promise<void>((resolve) => {
const poll = async () => {
try {
const { data, error } = await authClient.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceCode,
client_id: "demo-cli",
});
if (data?.access_token) {
console.log("\nAuthorization Successful!");
console.log("Access token received!");
// Get user session
const { data: session } = await authClient.getSession({
fetchOptions: {
headers: {
Authorization: `Bearer ${data.access_token}`,
},
},
});
console.log(`Hello, ${session?.user?.name || "User"}!`);
resolve();
process.exit(0);
} else if (error) {
switch (error.error) {
case "authorization_pending":
// Continue polling silently
break;
case "slow_down":
pollingInterval += 5;
console.log(`ā ļø Slowing down polling to ${pollingInterval}s`);
break;
case "access_denied":
console.error("ā Access was denied by the user");
process.exit(1);
break;
case "expired_token":
console.error("ā The device code has expired. Please try again.");
process.exit(1);
break;
default:
console.error("ā Error:", error.error_description);
process.exit(1);
}
}
} catch (err) {
console.error("ā Network error:", err.message);
process.exit(1);
}
// Schedule next poll
setTimeout(poll, pollingInterval * 1000);
};
// Start polling
setTimeout(poll, pollingInterval * 1000);
});
}
// Run the authentication flow
authenticateCLI().catch((err) => {
console.error("ā Fatal error:", err);
process.exit(1);
});
GET /device. The verification step claims the pending device code for the calling session, and only that session can later approve or deny itverificationUri: The URL of the verification page where users can enter their device code. Match this to the route of your verification page. Returned as verification_uri in the response. Can be an absolute URL (e.g., https://example.com/device) or relative path (e.g., /device). Default: /device.
expiresIn: The expiration time for device codes. Default: "30m" (30 minutes).
interval: The minimum polling interval. Default: "5s" (5 seconds).
userCodeLength: The length of the user code. Default: 8.
deviceCodeLength: The length of the device code. Default: 40.
generateDeviceCode: Custom function to generate device codes. Returns a string or Promise<string>.
generateUserCode: Custom function to generate user codes. Returns a string or Promise<string>.
validateClient: Function to validate client IDs. Takes a clientId and returns boolean or Promise<boolean>.
onDeviceAuthRequest: Hook called when device authorization is requested. Takes clientId and optional scope.
No client-specific configuration options. The plugin adds the following methods:
The plugin requires a new table to store device authorization data.
Table Name: deviceCode
export const deviceCodeTableFields = [ { name: "id", type: "string", description: "Unique identifier for the device authorization request", isPrimaryKey: true, }, { name: "deviceCode", type: "string", description: "The device verification code", }, { name: "userCode", type: "string", description: "The user-friendly code for verification", }, { name: "userId", type: "string", description: "The ID of the user who approved/denied", isOptional: true, }, { name: "clientId", type: "string", description: "The OAuth client identifier", isOptional: true, }, { name: "scope", type: "string", description: "Requested OAuth scopes", isOptional: true, }, { name: "status", type: "string", description: "Current status: pending, approved, or denied", }, { name: "expiresAt", type: "Date", description: "When the device code expires", }, { name: "lastPolledAt", type: "Date", description: "Last time the device polled for status", isOptional: true, }, { name: "pollingInterval", type: "number", description: "Minimum seconds between polls", isOptional: true, }, ];
<DatabaseTable name="deviceCode" fields={deviceCodeTableFields} />