You are currently viewing documentation for v1.8 (Beta)

Device Authorization

OAuth 2.0 Device Authorization Grant for limited-input devices

RFC 8628 CLI Smart TV IoT

The Device Authorization plugin implements the code issuance and approval flow from the OAuth 2.0 Device Authorization Grant (RFC 8628) for limited-input devices such as smart TVs, CLI applications, IoT devices, and gaming consoles. Use it on its own for Better Auth session tokens, or compose it with the OAuth Provider to issue OAuth access tokens.

Try It Out

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:

  1. Requesting a device code from the Better Auth demo server
  2. Displaying a user code for you to enter
  3. Opening your browser to the verification page
  4. Polling for authorization completion

The CLI login command is a demo feature that connects to the Better Auth demo server to showcase the device authorization flow in action.

Installation

Add the plugin to your auth config

Add the device authorization plugin to your server configuration.

auth.ts
import { betterAuth } from "better-auth";
import { deviceAuthorization } from "better-auth/plugins"; 

export const auth = betterAuth({
  // ... other config
  plugins: [
    deviceAuthorization({ 
      verificationUri: "/device", 
    }), 
  ],
});

Migrate the database

Run the migration or generate the schema to add the necessary tables to the database.

npx auth migrate

See the Schema section to add the fields manually.

Add the client plugin

Add the device authorization plugin to your client.

auth-client.ts
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins"; 

export const authClient = createAuthClient({
  plugins: [
    deviceAuthorizationClient(), 
  ],
});

How It Works

The device flow follows these steps:

  1. Device requests codes: The device requests a device code and user code from the authorization server
  2. User authorizes: The user visits a verification URL and enters the user code
  3. Device polls for token: The device polls the server until the user completes authorization
  4. Access granted: Once authorized, the device receives the session token or OAuth access token provided by the configured integration

Production security requirements

RFC 8628 requires the device to make outbound HTTPS requests and the user to authenticate at the verification URI in a secure TLS-protected session. In production, serve the verification and approval UI, device-code requests, and token polling over HTTPS. Use HTTP only for explicitly local development.

The approval UI is part of the security boundary. It must:

  • ask the user to enter the user_code, or, when using verification_uri_complete such as a QR code, ask the user to confirm that the displayed code matches the code on the device;
  • show what is being authorized: the client, requested scopes, and, when the OAuth Provider integration is enabled, the requested resource or resources;
  • require an explicit approval or denial; and
  • tell the user that they are authorizing a device in their possession and warn them not to approve unexpected requests or codes supplied through phishing messages.

For additional threat-model and mitigation guidance, see the IETF Cross-Device Flows: Security Best Current Practice.

Choose the token your device needs

Device authorization can finish with two different token types. Choose the token based on what the client needs to call:

Use caseTokenPluginsToken endpoint
Sign your own device into the same Better Auth applicationBetter Auth session tokendeviceAuthorization()/device/token
Let a registered CLI, TV app, or other public client call an OAuth-protected APIScoped OAuth access tokenjwt(), oauthProvider(), and oauthDeviceAuthorization()/oauth2/token

A command-line application commonly needs the second path. The CLI cannot safely keep a client secret, and it may not have a reliable redirect listener, but it still needs a user-authorized token for your API. The device grant lets the user approve the request in a browser while the CLI receives an audience-bound OAuth access token.

Authorize a CLI to call an API

Add JWT, OAuth Provider, and the OAuth Device Authorization integration:

auth.ts
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
import {
  oauthDeviceAuthorization,
  oauthProvider,
} from "@better-auth/oauth-provider"; 

export const auth = betterAuth({
  plugins: [
    jwt(),
    oauthProvider({
      loginPage: "/sign-in",
      consentPage: "/consent",
      scopes: ["openid", "profile", "offline_access", "api:read"],
      resources: ["https://api.example.com"],
    }),
    oauthDeviceAuthorization({ 
      verificationUri: "/device",
    }),
  ],
});

Register the CLI as a public native client and link it to the API resource. Public clients use token_endpoint_auth_method: "none" because an installed CLI cannot keep a shared secret confidential:

register-cli.ts
import { DEVICE_CODE_GRANT_TYPE } from "@better-auth/oauth-provider";

await auth.api.adminCreateOAuthClient({
  headers,
  body: {
    token_endpoint_auth_method: "none",
    type: "native",
    grant_types: [DEVICE_CODE_GRANT_TYPE, "refresh_token"],
    scope: "openid profile offline_access api:read",
    resources: ["https://api.example.com"],
  },
});

Public clients use token_endpoint_auth_method: "none" and must send client_id. A confidential client using client_secret_basic may authenticate at /device/code with an Authorization: Basic ... header and omit the body client_id; client_secret_post sends both values in the form body.

The CLI then requests a device code for the API it intends to call:

cli.ts
import { createAuthClient } from "better-auth/client";
import { oauthDeviceAuthorizationClient } from "@better-auth/oauth-provider/client";

const authBaseURL = "https://auth.example.com/api/auth";
const clientId = "YOUR_CLIENT_ID";
const resource = "https://api.example.com";

const authClient = createAuthClient({
  baseURL: authBaseURL,
  plugins: [oauthDeviceAuthorizationClient()],
});

const { data } = await authClient.device.code({
  client_id: clientId,
  scope: "openid profile offline_access api:read",
  resource,
});

if (!data) throw new Error("Could not start device authorization");

console.log(`Open ${data.verification_uri}`);
console.log(`Enter code ${data.user_code}`);

While the user signs in and approves the request, repeat the following request at the interval returned by the device-code response:

cli.ts
const response = await fetch(`${authBaseURL}/oauth2/token`, {
  method: "POST",
  headers: {
    "content-type": "application/x-www-form-urlencoded",
  },
  body: new URLSearchParams({
    grant_type: "urn:ietf:params:oauth:grant-type:device_code",
    device_code: data.device_code,
    client_id: clientId,
  }),
});

const tokens = await response.json();

The access token is a signed JWT whose aud identifies https://api.example.com. Send it to that API as Authorization: Bearer <access_token>, then verify its signature, issuer, audience, expiry, and required scopes at the API. See API Server verification. If the CLI requests offline_access, store the returned refresh token in the operating system's secure credential store and use it to renew short-lived access tokens.

Do not call authClient.device.token for this OAuth flow. That client method polls /device/token and returns a Better Auth session token. Registered OAuth clients must poll /oauth2/token.

oauthDeviceAuthorization() owns the Device Authorization endpoints and registers the urn:ietf:params:oauth:grant-type:device_code grant with OAuth Provider. The provider validates the registered client, scopes, and RFC 8707 resource before creating the request. The approval page can show the client, scopes, and resource to the signed-in user.

Both token paths can coexist. First-party device login keeps using /device/token. Registered OAuth clients use /oauth2/token; oauthDeviceAuthorization() prevents their device codes from being redeemed for a Better Auth session token.

First-party session flow

The rest of this page describes the first-party path, where /device/token returns a Better Auth session token.

Requesting Device Authorization

To initiate device authorization, call device.code with the client ID:

POST/device/code
const { data, error } = await authClient.device.code({    client_id, // required    scope,    user_id,});
Parameters
client_idstring;required

The device client identifier

scopestring;

Space-separated list of requested scopes (optional)

user_idstring;

The user ID to which the device code should be pre-bound. When set, only that user can approve or deny the code. Pass this from trusted server-side code only. (optional)

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}`);
}

Pre-binding to a User

If your server already knows which user a device belongs to, pass user_id when requesting the device code. The code is then bound to that user from the start. It skips the claiming step, and only the bound user can approve or deny it. Any other signed-in user receives an access_denied error.

This is useful when the user code is displayed where others can see it, because no one else can claim the code before the intended user verifies it.

server.ts
const data = await auth.api.deviceCode({
  body: {
    client_id: "your-client-id",
    scope: "openid profile email",
    user_id: user.id,
  },
});

Pass user_id from trusted server-side code. The parameter only restricts who can approve the code, so an untrusted device cannot use it to access another user's account. The protection is only meaningful when your server controls how codes are issued.

Polling for a Better Auth session token

After displaying the user code, poll for the Better Auth session token. The response's access_token field contains a Better Auth session token, not an RFC 8628 OAuth access token:

POST/device/token
const { data, error } = await authClient.device.token({    grant_type, // required    device_code, // required    client_id, // required});
Parameters
grant_typestring;required

Must be "urn:ietf:params:oauth:grant-type:device_code"

device_codestring;required

The device code from the initial request

client_idstring;required

The device client identifier

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();

User Authorization Flow

The user authorization flow requires two steps:

  1. Code Verification: Validate the user code via GET /device. The verification request claims the pending device code for the calling session.
  2. Authorization: The session that claimed the code can approve or deny it.

Users must be authenticated when calling GET /device, because the verification step binds the pending device code to that session. Only the same session can later approve or deny. If the user is not authenticated when entering the code, redirect them to the login page with a return URL and re-call GET /device after sign-in.

When the OAuth Provider integration is enabled, GET /device also returns the approved resource context to the authenticated user who owns the request. Show that context in the approval UI alongside the client and scopes. Standalone Device Authorization does not return an RFC 8707 resource field.

Create a page where users can enter their code:

app/device/page.tsx
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>
  );
}

Approving or Denying Device

Users must be authenticated to approve or deny device authorization requests:

Approve Device

POST/device/approve
const { data, error } = await authClient.device.approve({    userCode, // required});
Parameters
userCodestring;required

The user code to approve

Deny Device

POST/device/deny
const { data, error } = await authClient.device.deny({    userCode, // required});
Parameters
userCodestring;required

The user code to deny

Example Approval Page

app/device/approve/page.tsx
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 [request, setRequest] = useState<{
    client_id?: string;
    scope?: string;
    resource?: string | string[];
  } | null>(null);

  useEffect(() => {
    if (!user || !userCode) return;
    authClient.device({ query: { user_code: userCode } }).then(({ data }) => {
      setRequest(data);
    });
  }, [user, userCode]);
  
  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>Client: {request?.client_id}</p>
      <p>Scopes: {request?.scope || "None"}</p>
      <p>
        Resources: {Array.isArray(request?.resource)
          ? request.resource.join(", ")
          : request?.resource || "None"}
      </p>
      <p>Code: {userCode}</p>
      
      <button onClick={handleApprove} disabled={isProcessing}>
        Approve
      </button>
      <button onClick={handleDeny} disabled={isProcessing}>
        Deny
      </button>
    </div>
  );
}

Advanced Configuration

Client Validation

You can validate client IDs to ensure only authorized applications can use the device flow:

When the OAuth Provider integration is enabled, an unknown OAuth client ID reaches the standalone flow only when validateClient explicitly accepts it. Otherwise, the request returns invalid_client.

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);
  },
})

Custom Code Generation

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;
  },
})

Default user codes are case-insensitive and accept whitespace or punctuation inserted for readability when submitted to the verification, approve, or deny endpoints. Custom user codes are matched exactly when they contain characters outside the default ABCDEFGHJKLMNPQRSTUVWXYZ23456789 alphabet.

Device-code issuance makes up to 3 attempts to overcome unique-key collisions. If all attempts collide, /device/code returns server_error.

Error Handling

The device flow defines specific error codes:

Error CodeDescription
authorization_pendingUser hasn't approved yet (continue polling)
slow_downPolling too frequently (increase interval)
expired_tokenDevice code has expired
access_deniedUser denied the authorization
invalid_grantInvalid device code or client ID

Example: CLI Application

Here's a complete example for a CLI application based on the actual demo:

To use the access token for API requests, ensure you have added the Bearer plugin to your auth instance.

auth-client.ts
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);
});

Security Considerations

  1. Verification and polling limits: /device is limited to 5 requests over a window equal to the configured device-code lifetime. /device/token keeps its own polling interval and slow_down behavior
  2. Code Expiration: Device and user codes expire after the configured time (default: 30 minutes)
  3. Client Validation: Always validate client IDs in production to prevent unauthorized access
  4. HTTPS and approval UI: Follow the RFC 8628 TLS and user-interaction requirements in production, including explicit approval or denial and device-possession and remote-phishing guidance
  5. User Code Format: User codes use a limited character set (excluding similar-looking characters like 0/O, 1/I) to reduce typing errors
  6. Authentication Required: Users must be authenticated when calling GET /device. The verification step claims the pending device code for the calling session, and only that session can later approve or deny it
  7. Pre-binding: Device codes issued with user_id skip the claiming step and can only be approved or denied by that user. Pass user_id from trusted server-side code only

Options

Server

verificationUri: 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. Maximum: 191. Default: 8.

deviceCodeLength: The length of the device code. Maximum: 191. Default: 40.

generateDeviceCode: Custom function to generate device codes. Returns a string or Promise<string> with at most 191 characters.

generateUserCode: Custom function to generate user codes. Returns a string or Promise<string> with at most 191 characters.

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.

Client

No client-specific configuration options. The plugin adds the following methods:

  • device(): Verify user code validity
  • device.code(): Request device and user codes
  • device.token(): Poll for access token
  • device.approve(): Approve device (requires authentication)
  • device.deny(): Deny device (requires authentication)

Schema

The plugin requires a new table to store device authorization data.

Table Name: deviceCode

deviceCode and userCode have unique indexes because the device token, verification, approval, and denial flows use them as lookup fields. Both values are limited to 191 characters.

Before applying the migration, resolve duplicate deviceCode and userCode values on every adapter. MySQL and SQL Server installations must also convert both columns to bounded strings and clean up values longer than 191 characters.

Table
Field
Type
Attributes
Description
id
string
PK
Unique identifier for the device authorization request
deviceCode
string
UQ
The device verification code
userCode
string
UQ
The user-friendly code for verification
userId ?
string
-
The ID of the user who approved/denied
clientId ?
string
-
The device client identifier
scope ?
string
-
Requested scopes
status
string
-
Current status: pending, approved, or denied
expiresAt
Date
-
When the device code expires
lastPolledAt ?
Date
-
Last time the device polled for status
pollingInterval ?
number
-
Minimum seconds between polls

oauthDeviceAuthorization() extends this table with optional oauthClientId and resources fields. Standalone Device Authorization installations do not add those fields and do not expose the RFC 8707 resource request parameter.