OAuth Device Authorization Flow for ChatGPT Apps

The OAuth 2.1 device authorization flow (formerly RFC 8628) enables ChatGPT apps to authenticate users on input-constrained devices like smart TVs, IoT devices, CLI tools, and embedded systems. Unlike standard OAuth flows that require browser redirection, device flow uses a two-device model: the device displays a user code, and the user authorizes on a separate device (phone/computer).

This article demonstrates production-ready implementation of device flow for ChatGPT apps, with TypeScript examples covering device code generation, polling strategies, security validation, and UX optimization.

When to Use Device Authorization Flow

Device flow solves authentication challenges for input-constrained environments:

  1. Smart TV ChatGPT Apps: Users authenticate on their phone instead of typing credentials with a remote control
  2. IoT Devices: Thermostats, cameras, and sensors integrate ChatGPT without keyboards
  3. CLI Tools: Command-line utilities authenticate without launching browsers
  4. Embedded Systems: Kiosks and digital signage authenticate securely
  5. Gaming Consoles: PlayStation/Xbox ChatGPT integrations

The user experience is simple:

  1. Device displays a user code (e.g., BDWP-HQPM) and verification URL (https://auth.example.com/device)
  2. User visits URL on their phone/computer
  3. User enters code and authorizes the app
  4. Device polls for completion and receives access token

This flow is mandatory when the device cannot render web pages or handle OAuth redirects.

For implementation guidance on choosing OAuth flows, see our OAuth 2.1 Security Implementation Guide.

Device Flow Implementation Architecture

The device authorization flow has three core components:

1. Device Code Request

The device requests a device_code and user_code from the authorization server:

// src/oauth/device-flow-client.ts
import { randomBytes } from 'crypto';
import axios, { AxiosError } from 'axios';

interface DeviceCodeResponse {
  device_code: string;
  user_code: string;
  verification_uri: string;
  verification_uri_complete: string; // With pre-filled code
  expires_in: number; // Seconds
  interval: number; // Polling interval (seconds)
}

interface DeviceCodeError {
  error: string;
  error_description?: string;
}

export class DeviceFlowClient {
  private authServerUrl: string;
  private clientId: string;
  private scope: string;

  constructor(config: {
    authServerUrl: string;
    clientId: string;
    scope: string;
  }) {
    this.authServerUrl = config.authServerUrl;
    this.clientId = config.clientId;
    this.scope = config.scope;
  }

  /**
   * Request device and user codes from authorization server
   */
  async requestDeviceCode(): Promise<DeviceCodeResponse> {
    try {
      const response = await axios.post<DeviceCodeResponse>(
        `${this.authServerUrl}/oauth/device/code`,
        new URLSearchParams({
          client_id: this.clientId,
          scope: this.scope,
        }),
        {
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
        }
      );

      // Validate response
      this.validateDeviceCodeResponse(response.data);

      return response.data;
    } catch (error) {
      if (axios.isAxiosError(error)) {
        const axiosError = error as AxiosError<DeviceCodeError>;
        throw new Error(
          `Device code request failed: ${
            axiosError.response?.data?.error_description ||
            axiosError.message
          }`
        );
      }
      throw error;
    }
  }

  /**
   * Validate device code response structure
   */
  private validateDeviceCodeResponse(response: DeviceCodeResponse): void {
    const required = [
      'device_code',
      'user_code',
      'verification_uri',
      'expires_in',
      'interval',
    ];

    for (const field of required) {
      if (!(field in response)) {
        throw new Error(`Missing required field: ${field}`);
      }
    }

    if (response.interval < 5) {
      throw new Error('Polling interval must be at least 5 seconds');
    }

    if (response.expires_in < 300) {
      throw new Error('Expiration must be at least 5 minutes');
    }
  }

  /**
   * Display user instructions (CLI example)
   */
  displayInstructions(response: DeviceCodeResponse): void {
    console.log('\n===========================================');
    console.log('  ChatGPT App Authorization Required');
    console.log('===========================================\n');
    console.log(`1. Visit: ${response.verification_uri}`);
    console.log(`2. Enter code: ${response.user_code}`);
    console.log(
      `3. Or scan QR code (if verification_uri_complete available)\n`
    );
    console.log(
      `Code expires in ${Math.floor(response.expires_in / 60)} minutes`
    );
    console.log('===========================================\n');
    console.log('Waiting for authorization...');
  }
}

2. User Authorization

User visits verification_uri, enters user_code, and authorizes the app:

// src/oauth/authorization-server.ts
import express, { Request, Response } from 'express';
import { randomBytes, createHash } from 'crypto';
import jwt from 'jsonwebtoken';

interface DeviceCodeEntry {
  device_code: string;
  user_code: string;
  client_id: string;
  scope: string;
  created_at: Date;
  expires_at: Date;
  status: 'pending' | 'authorized' | 'denied' | 'expired';
  user_id?: string; // Set after authorization
}

// In-memory store (use Redis in production)
const deviceCodes = new Map<string, DeviceCodeEntry>();
const userCodeIndex = new Map<string, string>(); // user_code -> device_code

export class DeviceAuthorizationServer {
  private router = express.Router();
  private jwtSecret: string;
  private deviceCodeTTL = 900; // 15 minutes
  private pollingInterval = 5; // 5 seconds

  constructor(jwtSecret: string) {
    this.jwtSecret = jwtSecret;
    this.setupRoutes();
    this.startCleanupJob();
  }

  private setupRoutes(): void {
    // Device code request endpoint
    this.router.post('/oauth/device/code', this.handleDeviceCodeRequest.bind(this));

    // User verification page
    this.router.get('/device', this.renderVerificationPage.bind(this));

    // User code submission
    this.router.post('/device/authorize', this.handleUserAuthorization.bind(this));

    // Token polling endpoint
    this.router.post('/oauth/device/token', this.handleTokenRequest.bind(this));
  }

  /**
   * Handle device code request (step 1)
   */
  private async handleDeviceCodeRequest(req: Request, res: Response): Promise<void> {
    const { client_id, scope } = req.body;

    // Validate client_id (check against registered clients)
    if (!this.isValidClient(client_id)) {
      res.status(400).json({
        error: 'invalid_client',
        error_description: 'Unknown client_id',
      });
      return;
    }

    // Generate codes
    const device_code = this.generateDeviceCode();
    const user_code = this.generateUserCode();

    const now = new Date();
    const expires_at = new Date(now.getTime() + this.deviceCodeTTL * 1000);

    const entry: DeviceCodeEntry = {
      device_code,
      user_code,
      client_id,
      scope: scope || 'openid profile',
      created_at: now,
      expires_at,
      status: 'pending',
    };

    deviceCodes.set(device_code, entry);
    userCodeIndex.set(user_code, device_code);

    const baseUrl = process.env.AUTH_SERVER_BASE_URL || 'https://auth.example.com';

    res.json({
      device_code,
      user_code,
      verification_uri: `${baseUrl}/device`,
      verification_uri_complete: `${baseUrl}/device?user_code=${user_code}`,
      expires_in: this.deviceCodeTTL,
      interval: this.pollingInterval,
    });
  }

  /**
   * Generate cryptographically secure device code
   */
  private generateDeviceCode(): string {
    return randomBytes(32).toString('base64url');
  }

  /**
   * Generate human-readable user code (format: BDWP-HQPM)
   */
  private generateUserCode(): string {
    const chars = 'BCDFGHJKLMNPQRSTVWXZ'; // Consonants (avoid confusion)
    let code = '';

    for (let i = 0; i < 8; i++) {
      code += chars[Math.floor(Math.random() * chars.length)];
      if (i === 3) code += '-'; // Add hyphen after 4 chars
    }

    // Check collision
    if (userCodeIndex.has(code)) {
      return this.generateUserCode(); // Recursive retry
    }

    return code;
  }

  /**
   * Render user verification page
   */
  private renderVerificationPage(req: Request, res: Response): void {
    const prefilledCode = req.query.user_code as string | undefined;

    res.send(`
      <!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Device Authorization</title>
        <style>
          body { font-family: Arial, sans-serif; max-width: 500px; margin: 50px auto; padding: 20px; }
          h1 { color: #0A0E27; }
          input { font-size: 24px; letter-spacing: 5px; text-transform: uppercase; padding: 10px; width: 100%; }
          button { background: #D4AF37; color: #0A0E27; padding: 15px 30px; border: none; font-size: 18px; cursor: pointer; margin-top: 20px; }
          button:hover { background: #C49F27; }
          .error { color: red; margin-top: 10px; }
        </style>
      </head>
      <body>
        <h1>Authorize ChatGPT App</h1>
        <p>Enter the code displayed on your device:</p>
        <form id="authForm" action="/device/authorize" method="POST">
          <input
            type="text"
            name="user_code"
            id="userCode"
            placeholder="XXXX-XXXX"
            maxlength="9"
            value="${prefilledCode || ''}"
            autofocus
            required
          />
          <button type="submit">Continue</button>
        </form>
        <div id="error" class="error"></div>
      </body>
      </html>
    `);
  }

  /**
   * Handle user authorization (step 2)
   */
  private async handleUserAuthorization(req: Request, res: Response): Promise<void> {
    const { user_code } = req.body;
    const user_id = req.user?.id; // Assume user is authenticated via session

    if (!user_id) {
      res.status(401).send('Please log in first');
      return;
    }

    const device_code = userCodeIndex.get(user_code.toUpperCase());
    if (!device_code) {
      res.status(400).send('Invalid code');
      return;
    }

    const entry = deviceCodes.get(device_code);
    if (!entry) {
      res.status(400).send('Code expired or invalid');
      return;
    }

    // Check expiration
    if (new Date() > entry.expires_at) {
      entry.status = 'expired';
      res.status(400).send('Code expired');
      return;
    }

    // Mark as authorized
    entry.status = 'authorized';
    entry.user_id = user_id;

    res.send(`
      <!DOCTYPE html>
      <html>
      <head><title>Success</title></head>
      <body>
        <h1>Authorization Successful!</h1>
        <p>You can close this window and return to your device.</p>
      </body>
      </html>
    `);
  }

  /**
   * Validate client_id against registered clients
   */
  private isValidClient(client_id: string): boolean {
    // TODO: Check against database of registered clients
    return client_id.length > 0;
  }

  /**
   * Cleanup expired device codes (run every 5 minutes)
   */
  private startCleanupJob(): void {
    setInterval(() => {
      const now = new Date();
      for (const [device_code, entry] of deviceCodes.entries()) {
        if (now > entry.expires_at) {
          deviceCodes.delete(device_code);
          userCodeIndex.delete(entry.user_code);
        }
      }
    }, 5 * 60 * 1000); // 5 minutes
  }

  getRouter(): express.Router {
    return this.router;
  }
}

3. Token Polling

Device polls the token endpoint until user authorizes:

// src/oauth/device-flow-polling.ts
import axios, { AxiosError } from 'axios';

interface TokenResponse {
  access_token: string;
  token_type: 'Bearer';
  expires_in: number;
  refresh_token?: string;
  scope: string;
}

interface TokenError {
  error: 'authorization_pending' | 'slow_down' | 'access_denied' | 'expired_token';
  error_description?: string;
}

export class DeviceTokenPoller {
  private authServerUrl: string;
  private clientId: string;
  private deviceCode: string;
  private interval: number; // seconds
  private maxRetries: number;

  constructor(config: {
    authServerUrl: string;
    clientId: string;
    deviceCode: string;
    interval: number;
    maxRetries?: number;
  }) {
    this.authServerUrl = config.authServerUrl;
    this.clientId = config.clientId;
    this.deviceCode = config.deviceCode;
    this.interval = config.interval;
    this.maxRetries = config.maxRetries || 120; // 120 retries = 10 minutes at 5s interval
  }

  /**
   * Poll for access token with exponential backoff
   */
  async pollForToken(): Promise<TokenResponse> {
    let retries = 0;
    let currentInterval = this.interval;

    while (retries < this.maxRetries) {
      await this.sleep(currentInterval * 1000);

      try {
        const response = await axios.post<TokenResponse>(
          `${this.authServerUrl}/oauth/device/token`,
          new URLSearchParams({
            grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
            device_code: this.deviceCode,
            client_id: this.clientId,
          }),
          {
            headers: {
              'Content-Type': 'application/x-www-form-urlencoded',
            },
          }
        );

        // Success!
        return response.data;
      } catch (error) {
        if (axios.isAxiosError(error)) {
          const axiosError = error as AxiosError<TokenError>;
          const errorData = axiosError.response?.data;

          if (errorData?.error === 'authorization_pending') {
            // Keep polling
            retries++;
            console.log(`Waiting for user authorization... (${retries}/${this.maxRetries})`);
            continue;
          } else if (errorData?.error === 'slow_down') {
            // Server requested slower polling (add 5 seconds)
            currentInterval += 5;
            console.log(`Slowing down polling to ${currentInterval}s intervals`);
            retries++;
            continue;
          } else if (errorData?.error === 'access_denied') {
            throw new Error('User denied authorization');
          } else if (errorData?.error === 'expired_token') {
            throw new Error('Device code expired. Please restart authorization.');
          } else {
            throw new Error(
              `Token request failed: ${errorData?.error_description || axiosError.message}`
            );
          }
        }
        throw error;
      }
    }

    throw new Error('Polling timeout: User did not authorize in time');
  }

  /**
   * Sleep utility
   */
  private sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }
}

For additional OAuth flow patterns, see our guides on OAuth PKCE Implementation and Client Credentials Flow.

User Code Generation Best Practices

User codes must be human-readable, collision-resistant, and short-lived:

Code Format Design

// src/oauth/user-code-generator.ts
import { randomBytes, createHash } from 'crypto';

export interface UserCodeConfig {
  length: number; // Total characters (excluding separator)
  separator?: string; // Default: '-'
  charset?: string; // Default: consonants
  ttl: number; // Seconds
  collisionRetries?: number; // Default: 3
}

export class UserCodeGenerator {
  private config: Required<UserCodeConfig>;
  private usedCodes = new Set<string>();

  constructor(config: UserCodeConfig) {
    this.config = {
      length: config.length,
      separator: config.separator || '-',
      charset: config.charset || 'BCDFGHJKLMNPQRSTVWXZ', // Avoid vowels (prevent profanity)
      ttl: config.ttl,
      collisionRetries: config.collisionRetries || 3,
    };
  }

  /**
   * Generate user code with collision detection
   */
  generate(): string {
    for (let attempt = 0; attempt < this.config.collisionRetries; attempt++) {
      const code = this.generateCode();

      if (!this.usedCodes.has(code)) {
        this.usedCodes.add(code);

        // Auto-expire from cache after TTL
        setTimeout(() => {
          this.usedCodes.delete(code);
        }, this.config.ttl * 1000);

        return code;
      }
    }

    throw new Error('Failed to generate unique code after retries');
  }

  /**
   * Generate random code from charset
   */
  private generateCode(): string {
    let code = '';
    const { length, charset, separator } = this.config;

    for (let i = 0; i < length; i++) {
      // Add separator at midpoint (e.g., after 4 chars for 8-char code)
      if (separator && i === Math.floor(length / 2)) {
        code += separator;
      }

      const randomIndex = randomBytes(1)[0] % charset.length;
      code += charset[randomIndex];
    }

    return code;
  }

  /**
   * Validate user-submitted code format
   */
  validate(code: string): boolean {
    const { length, separator, charset } = this.config;

    // Remove separator for validation
    const cleanCode = code.replace(separator, '');

    if (cleanCode.length !== length) {
      return false;
    }

    // Check all characters are in charset
    for (const char of cleanCode) {
      if (!charset.includes(char)) {
        return false;
      }
    }

    return true;
  }

  /**
   * Calculate collision probability
   * Formula: 1 - e^(-n^2 / 2m)
   * Where n = number of codes, m = total possible codes
   */
  calculateCollisionProbability(activeCodes: number): number {
    const { length, charset } = this.config;
    const totalPossible = Math.pow(charset.length, length);

    // Birthday paradox formula
    const probability = 1 - Math.exp(-Math.pow(activeCodes, 2) / (2 * totalPossible));

    return probability;
  }

  /**
   * Get statistics
   */
  getStats(): {
    activeCodeCount: number;
    totalPossible: number;
    collisionProbability: number;
  } {
    const totalPossible = Math.pow(this.config.charset.length, this.config.length);
    const activeCodeCount = this.usedCodes.size;

    return {
      activeCodeCount,
      totalPossible,
      collisionProbability: this.calculateCollisionProbability(activeCodeCount),
    };
  }
}

// Example usage
const generator = new UserCodeGenerator({
  length: 8, // XXXX-XXXX
  ttl: 900, // 15 minutes
});

const userCode = generator.generate(); // e.g., "BDWP-HQPM"
console.log('User code:', userCode);

const stats = generator.getStats();
console.log('Collision probability:', (stats.collisionProbability * 100).toFixed(6) + '%');

Best Practices:

  • Length: 8 characters minimum (20^8 = 25.6 billion combinations)
  • Charset: Use consonants to prevent accidental profanity
  • Separator: Add hyphen for readability (BDWP-HQPM vs BDWPHQPM)
  • TTL: 15 minutes (balance security and UX)
  • Case-Insensitive: Always normalize to uppercase

Polling Strategies and Backoff

Proper polling prevents server overload and respects rate limits:

Intelligent Polling Implementation

// src/oauth/adaptive-poller.ts
import axios, { AxiosError } from 'axios';

interface PollingConfig {
  initialInterval: number; // seconds
  maxInterval: number; // seconds
  backoffMultiplier: number; // Default: 1.5
  maxRetries: number;
  jitter: boolean; // Add randomness to prevent thundering herd
}

interface PollingResult<T> {
  success: boolean;
  data?: T;
  error?: string;
  retries: number;
  totalTime: number; // milliseconds
}

export class AdaptivePoller<T> {
  private config: Required<PollingConfig>;
  private currentInterval: number;
  private retries = 0;
  private startTime = 0;

  constructor(config: Partial<PollingConfig>) {
    this.config = {
      initialInterval: config.initialInterval || 5,
      maxInterval: config.maxInterval || 30,
      backoffMultiplier: config.backoffMultiplier || 1.5,
      maxRetries: config.maxRetries || 120,
      jitter: config.jitter !== false, // Default true
    };

    this.currentInterval = this.config.initialInterval;
  }

  /**
   * Poll with exponential backoff and jitter
   */
  async poll(
    pollFn: () => Promise<T>,
    continueFn: (error: any) => boolean
  ): Promise<PollingResult<T>> {
    this.startTime = Date.now();

    while (this.retries < this.config.maxRetries) {
      // Calculate sleep time with jitter
      const sleepTime = this.calculateSleepTime();
      await this.sleep(sleepTime);

      try {
        const data = await pollFn();

        return {
          success: true,
          data,
          retries: this.retries,
          totalTime: Date.now() - this.startTime,
        };
      } catch (error) {
        this.retries++;

        // Check if we should continue polling
        if (!continueFn(error)) {
          return {
            success: false,
            error: error instanceof Error ? error.message : 'Unknown error',
            retries: this.retries,
            totalTime: Date.now() - this.startTime,
          };
        }

        // Apply backoff for next iteration
        this.applyBackoff();
      }
    }

    return {
      success: false,
      error: 'Polling timeout exceeded',
      retries: this.retries,
      totalTime: Date.now() - this.startTime,
    };
  }

  /**
   * Calculate sleep time with optional jitter
   */
  private calculateSleepTime(): number {
    let sleepMs = this.currentInterval * 1000;

    if (this.config.jitter) {
      // Add ±20% jitter to prevent thundering herd
      const jitterRange = sleepMs * 0.2;
      const jitter = (Math.random() - 0.5) * 2 * jitterRange;
      sleepMs += jitter;
    }

    return Math.max(0, sleepMs);
  }

  /**
   * Apply exponential backoff
   */
  private applyBackoff(): void {
    this.currentInterval = Math.min(
      this.currentInterval * this.config.backoffMultiplier,
      this.config.maxInterval
    );
  }

  /**
   * Reset slow_down (server-requested backoff)
   */
  slowDown(additionalSeconds: number = 5): void {
    this.currentInterval = Math.min(
      this.currentInterval + additionalSeconds,
      this.config.maxInterval
    );
  }

  private sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  /**
   * Get current polling state
   */
  getState(): {
    currentInterval: number;
    retries: number;
    elapsedTime: number;
  } {
    return {
      currentInterval: this.currentInterval,
      retries: this.retries,
      elapsedTime: this.startTime ? Date.now() - this.startTime : 0,
    };
  }
}

// Example usage with device flow
async function pollDeviceToken(deviceCode: string): Promise<TokenResponse> {
  const poller = new AdaptivePoller<TokenResponse>({
    initialInterval: 5,
    maxInterval: 30,
    maxRetries: 120,
  });

  const result = await poller.poll(
    async () => {
      const response = await axios.post(
        'https://auth.example.com/oauth/device/token',
        new URLSearchParams({
          grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
          device_code: deviceCode,
          client_id: 'chatgpt-cli-app',
        })
      );
      return response.data;
    },
    (error) => {
      if (axios.isAxiosError(error)) {
        const errorCode = error.response?.data?.error;

        if (errorCode === 'authorization_pending') {
          return true; // Continue polling
        } else if (errorCode === 'slow_down') {
          poller.slowDown(5); // Add 5 seconds
          return true;
        } else if (errorCode === 'access_denied' || errorCode === 'expired_token') {
          return false; // Stop polling
        }
      }
      return false;
    }
  );

  if (!result.success) {
    throw new Error(result.error);
  }

  console.log(`Token obtained after ${result.retries} retries (${result.totalTime}ms)`);
  return result.data!;
}

Polling Best Practices:

  • Initial Interval: 5 seconds (OAuth 2.1 recommendation)
  • Max Interval: 30 seconds (prevent excessive delays)
  • Backoff Multiplier: 1.5x (gradual increase)
  • Jitter: ±20% randomness (prevent synchronized requests)
  • Respect slow_down: Add 5+ seconds when server requests it

Security Considerations

Device flow introduces unique security risks:

1. Code Verification and Phishing Prevention

// src/oauth/device-security-validator.ts
import { createHash, timingSafeEqual } from 'crypto';

export class DeviceSecurityValidator {
  /**
   * Validate user code with timing-safe comparison
   */
  static validateUserCode(submitted: string, stored: string): boolean {
    // Normalize both codes
    const normalizedSubmitted = submitted.toUpperCase().replace(/[^A-Z]/g, '');
    const normalizedStored = stored.toUpperCase().replace(/[^A-Z]/g, '');

    // Constant-time comparison to prevent timing attacks
    if (normalizedSubmitted.length !== normalizedStored.length) {
      return false;
    }

    const bufferSubmitted = Buffer.from(normalizedSubmitted, 'utf-8');
    const bufferStored = Buffer.from(normalizedStored, 'utf-8');

    return timingSafeEqual(bufferSubmitted, bufferStored);
  }

  /**
   * Validate verification URI to prevent phishing
   */
  static validateVerificationUri(uri: string, allowedHosts: string[]): boolean {
    try {
      const url = new URL(uri);

      // Enforce HTTPS
      if (url.protocol !== 'https:') {
        return false;
      }

      // Check against allowlist
      if (!allowedHosts.includes(url.hostname)) {
        return false;
      }

      return true;
    } catch {
      return false;
    }
  }

  /**
   * Rate limit code verification attempts (prevent brute force)
   */
  static createRateLimiter(maxAttempts: number, windowMs: number) {
    const attempts = new Map<string, number[]>();

    return {
      check(identifier: string): { allowed: boolean; remainingAttempts: number } {
        const now = Date.now();
        const userAttempts = attempts.get(identifier) || [];

        // Remove attempts outside time window
        const validAttempts = userAttempts.filter(
          (timestamp) => now - timestamp < windowMs
        );

        if (validAttempts.length >= maxAttempts) {
          return { allowed: false, remainingAttempts: 0 };
        }

        // Record this attempt
        validAttempts.push(now);
        attempts.set(identifier, validAttempts);

        return {
          allowed: true,
          remainingAttempts: maxAttempts - validAttempts.length,
        };
      },
    };
  }

  /**
   * Detect suspicious device patterns
   */
  static detectSuspiciousDevice(metadata: {
    ip: string;
    userAgent: string;
    requestCount: number;
    timeWindow: number; // ms
  }): { suspicious: boolean; reason?: string } {
    // Check for excessive requests from same IP
    if (metadata.requestCount > 100 && metadata.timeWindow < 3600000) {
      return {
        suspicious: true,
        reason: 'Excessive requests from IP',
      };
    }

    // Check for missing/suspicious user agent
    if (!metadata.userAgent || metadata.userAgent.length < 10) {
      return {
        suspicious: true,
        reason: 'Invalid user agent',
      };
    }

    return { suspicious: false };
  }
}

// Example usage in authorization endpoint
const rateLimiter = DeviceSecurityValidator.createRateLimiter(5, 60000); // 5 attempts per minute

app.post('/device/authorize', (req, res) => {
  const { user_code } = req.body;
  const userIp = req.ip;

  // Rate limit by IP
  const rateCheck = rateLimiter.check(userIp);
  if (!rateCheck.allowed) {
    res.status(429).json({
      error: 'too_many_requests',
      error_description: 'Too many verification attempts. Please wait.',
    });
    return;
  }

  // Validate code (timing-safe)
  const storedCode = getStoredCode(user_code); // Fetch from database
  if (!storedCode || !DeviceSecurityValidator.validateUserCode(user_code, storedCode)) {
    res.status(400).json({
      error: 'invalid_code',
      error_description: `Invalid code. ${rateCheck.remainingAttempts} attempts remaining.`,
    });
    return;
  }

  // Proceed with authorization...
});

2. Device Trust Indicators

Display security context to users during authorization:

// src/oauth/device-trust-indicator.tsx
import React from 'react';

interface DeviceInfo {
  deviceType: string; // e.g., "Smart TV", "CLI Tool"
  ipAddress: string;
  location?: string; // From IP geolocation
  lastSeen?: Date;
}

export const DeviceTrustIndicator: React.FC<{ device: DeviceInfo }> = ({ device }) => {
  return (
    <div style={{
      border: '2px solid #D4AF37',
      borderRadius: '8px',
      padding: '16px',
      margin: '20px 0',
      backgroundColor: '#F9F9F9',
    }}>
      <h3 style={{ margin: '0 0 12px 0', color: '#0A0E27' }}>Device Information</h3>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <tbody>
          <tr>
            <td style={{ padding: '8px 0', fontWeight: 'bold' }}>Device Type:</td>
            <td>{device.deviceType}</td>
          </tr>
          <tr>
            <td style={{ padding: '8px 0', fontWeight: 'bold' }}>IP Address:</td>
            <td>{device.ipAddress}</td>
          </tr>
          {device.location && (
            <tr>
              <td style={{ padding: '8px 0', fontWeight: 'bold' }}>Location:</td>
              <td>{device.location}</td>
            </tr>
          )}
          {device.lastSeen && (
            <tr>
              <td style={{ padding: '8px 0', fontWeight: 'bold' }}>Last Seen:</td>
              <td>{device.lastSeen.toLocaleString()}</td>
            </tr>
          )}
        </tbody>
      </table>
      <p style={{
        marginTop: '12px',
        fontSize: '14px',
        color: '#666',
      }}>
        ⚠️ Only authorize if you recognize this device and location.
      </p>
    </div>
  );
};

Security Checklist:

  • ✅ Enforce HTTPS for verification_uri
  • ✅ Use timing-safe code comparison
  • ✅ Rate limit verification attempts (5 per minute)
  • ✅ Display device metadata (IP, location, device type)
  • ✅ Expire codes after 15 minutes
  • ✅ Log all authorization events
  • ✅ Implement CSRF protection on authorization endpoint

User Experience Optimization

Device flow UX can make or break adoption:

QR Code Generation

Reduce friction by generating QR codes for verification_uri_complete:

// src/oauth/qr-code-generator.ts
import QRCode from 'qrcode';

export class DeviceFlowQRGenerator {
  /**
   * Generate QR code for verification URI
   */
  static async generate(verificationUriComplete: string): Promise<string> {
    try {
      // Generate data URL (base64 PNG)
      const qrDataUrl = await QRCode.toDataURL(verificationUriComplete, {
        errorCorrectionLevel: 'H', // High error correction (30%)
        type: 'image/png',
        width: 300,
        margin: 2,
        color: {
          dark: '#0A0E27', // Navy
          light: '#FFFFFF', // White
        },
      });

      return qrDataUrl;
    } catch (error) {
      throw new Error(`QR generation failed: ${error}`);
    }
  }

  /**
   * Display QR code in CLI
   */
  static async displayInTerminal(verificationUriComplete: string): Promise<void> {
    try {
      // Generate ASCII QR code for terminal
      const qrAscii = await QRCode.toString(verificationUriComplete, {
        type: 'terminal',
        errorCorrectionLevel: 'L',
      });

      console.log('\nScan this QR code to authorize:\n');
      console.log(qrAscii);
    } catch (error) {
      console.error('QR code generation failed:', error);
    }
  }
}

// Example: Enhanced device flow client with QR
class DeviceFlowWithQR extends DeviceFlowClient {
  async displayInstructions(response: DeviceCodeResponse): Promise<void> {
    super.displayInstructions(response); // Text instructions

    // Add QR code
    const qrDataUrl = await DeviceFlowQRGenerator.generate(
      response.verification_uri_complete
    );

    console.log('\nOr scan QR code:');
    console.log(qrDataUrl); // In GUI apps, display as <img>

    // For CLI, show ASCII QR
    await DeviceFlowQRGenerator.displayInTerminal(
      response.verification_uri_complete
    );
  }
}

UX Best Practices:

  • QR Codes: Pre-fill user_code via verification_uri_complete
  • Clear Instructions: Number steps, use large fonts for codes
  • Error Messages: Explain what went wrong and how to retry
  • Progress Indicators: Show polling status (Waiting for authorization...)
  • Expiration Warnings: Notify when code is about to expire
  • Success Confirmation: Show checkmark when authorization completes

For device flow in IoT contexts, see our IoT Integration Guide.

Conclusion

OAuth 2.1 device authorization flow enables seamless authentication for input-constrained ChatGPT apps on smart TVs, IoT devices, CLI tools, and embedded systems. Key implementation requirements:

  1. Device Code Generation: Cryptographically secure device_code, human-readable user_code
  2. Polling Strategy: 5-second initial interval, exponential backoff, jitter to prevent thundering herd
  3. Security Validation: Timing-safe code comparison, rate limiting, HTTPS enforcement
  4. UX Optimization: QR codes, clear instructions, progress indicators

Production considerations:

  • Store device codes in Redis (not in-memory) for horizontal scaling
  • Implement IP-based rate limiting (5 attempts per minute)
  • Log all authorization events for audit trails
  • Monitor polling load and adjust slow_down thresholds
  • Use short-lived codes (15 minutes) to minimize exposure

For comprehensive OAuth 2.1 implementation patterns, revisit our OAuth 2.1 Security Implementation Guide.

Build ChatGPT Apps with Zero OAuth Complexity

MakeAIHQ generates production-ready device flow implementations automatically:

  • Auto-Generated MCP Servers: Device code endpoints, polling logic, security validation
  • QR Code Integration: Built-in QR generation for mobile authorization
  • Rate Limiting: Pre-configured anti-abuse protections
  • OpenAI Compliance: OAuth 2.1 PKCE + device flow for ChatGPT App Store approval

Start building in 5 minutes: https://makeaihq.com/signup

Transform your ChatGPT app idea into a production-ready IoT integration—no OAuth expertise required.


Last updated: December 2026