Device Authorization

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.

Try It Out

You can test the device authorization flow right now using the Better Auth CLI:

npx @better-auth/cli 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({ 
      // Optional configuration
      expiresIn: "30m", // Device code expiration time
      interval: "5s",    // Minimum polling interval
    }), 
  ], 
});

Migrate the database

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

npx @better-auth/cli migrate
npx @better-auth/cli generate

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 an access token

Basic Usage

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,});
PropDescriptionType
client_id
The OAuth client identifier
string;
scope?
Space-separated list of requested scopes (optional)
string;

Example usage:

const { data } = await authClient.device.code({
  client_id: "your-client-id",
  scope: "openid profile email",
});

if (data) {
  console.log(`Please visit: ${data.verification_uri}`);
  console.log(`And enter code: ${data.user_code}`);
}

Polling for Token

After displaying the user code, poll for the access token:

POST
/device/token
const { data, error } = await authClient.device.token({    grant_type, // required    device_code, // required    client_id, // required});
PropDescriptionType
grant_type
Must be "urn:ietf:params:oauth:grant-type:device_code"
string;
device_code
The device code from the initial request
string;
client_id
The OAuth client identifier
string;

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: Check if the entered user code is valid
  2. Authorization: User must be authenticated to approve/deny the device

Users must be authenticated before they can approve or deny device authorization requests. If not authenticated, redirect them to the login page with a return URL.

Create a page where users can enter their code:

app/device/page.tsx
export default function DeviceAuthorizationPage() {
  const [userCode, setUserCode] = useState("");
  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();

      // Check if the code is valid using GET /device endpoint
      const response = await authClient.device.deviceVerify({
        query: { user_code: formattedCode },
      });
      
      if (response.data) {
        // Redirect to approval page
        window.location.href = `/device/approve?user_code=${formattedCode}`;
      }
    } 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});
PropDescriptionType
userCode
The user code to approve
string;

Deny Device

POST
/device/deny
const { data, error } = await authClient.device.deny({    userCode, // required});
PropDescriptionType
userCode
The user code to deny
string;

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("userCode");
  const [isProcessing, setIsProcessing] = useState(false);
  
  const handleApprove = async () => {
    setIsProcessing(true);
    try {
      await authClient.device.deviceApprove({
        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.deviceDeny({
        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
    window.location.href = `/login?redirect=/device/approve?user_code=${userCode}`;
    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>
  );
}

Advanced Configuration

Client Validation

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

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

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:

cli-auth.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 with the complete URL
    const urlToOpen = verification_uri_complete || verification_uri;
    if (urlToOpen) {
      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("\n✅ Authorization 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. Rate Limiting: The plugin enforces polling intervals to prevent abuse
  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 Only: Always use HTTPS in production for device authorization
  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 before they can approve or deny device requests

Options

Server

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.

Client

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

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

Schema

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

Table Name: deviceCode

Field NameTypeKeyDescription
idstringUnique identifier for the device authorization request
deviceCodestring-The device verification code
userCodestring-The user-friendly code for verification
userIdstringThe ID of the user who approved/denied
clientIdstringThe OAuth client identifier
scopestringRequested OAuth scopes
statusstring-Current status: pending, approved, or denied
expiresAtDate-When the device code expires
lastPolledAtDateLast time the device polled for status
pollingIntervalnumberMinimum seconds between polls
createdAtDate-When the request was created
updatedAtDate-When the request was last updated