Device Pairing Flow

Focus

Persistent Device Authentication

The device pairing flow enables mobile and IoT devices to establish persistent authentication without sharing static secrets. Once paired, a device token allows reconnection without re-entering credentials.

Auth Method device-token in GatewayAuthResult.method
Rate Limit Scope AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN
Key File auth.ts — authorizeGatewayConnect()
Security Model One-time pairing code exchanged for persistent token

Table of Contents

Overview

Device pairing solves the problem of authenticating devices that cannot easily enter passwords or manage bearer tokens. Instead, a short-lived pairing code is displayed on a trusted device (e.g., the CLI), and the new device enters this code to receive a long-lived device token.

🔒 Security note: The pairing code has a short TTL and is single-use. After successful pairing, only the device token is used for subsequent connections. The token is scoped to the AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN rate limit scope.

Pairing Protocol

sequenceDiagram
    participant D as New Device
    participant GW as Gateway
    participant Auth as Auth Layer
    participant RL as Rate Limiter
    participant T as Trusted Device

    T->>GW: Request pairing code
    GW->>GW: Generate short-lived code
    GW-->>T: Display pairing code

    D->>GW: Connect with pairing code
    GW->>RL: check(ip, "device-token")
    alt rate limited
        RL-->>D: {allowed: false, retryAfterMs}
    end
    RL-->>GW: {allowed: true}
    GW->>Auth: validate pairing code
    Auth->>Auth: verify code not expired
    Auth->>Auth: verify code not already used
    Auth-->>GW: code valid
    GW->>GW: Generate device token
    GW-->>D: {ok: true, method: "device-token", deviceToken}
    Note over D: Store token securely

    D->>GW: Reconnect with device token
    GW->>Auth: authorizeGatewayConnect({connectAuth: {deviceToken}})
    Auth-->>D: {ok: true, method: "device-token"}

How It Works

Pairing Code Generation

The trusted device (typically the CLI running on the same machine as the Gateway) requests a new pairing code. The Gateway generates a cryptographically random code with a short TTL (typically 5 minutes).

// The pairing code is a short alphanumeric string
// displayed to the user for manual entry on the new device
const pairingCode = generatePairingCode();
// TTL ensures abandoned pairing attempts expire quickly

New Device Submits Code

The new device connects to the Gateway and submits the pairing code via ConnectParams. The Gateway first checks rate limiting under the "device-token" scope.

// Rate limiting protects against brute-force pairing attempts
const rlResult = rateLimiter.check(
  normalizeRateLimitClientIp(clientIp),
  AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
);
if (!rlResult.allowed) {
  return { ok: false, rateLimited: true, retryAfterMs: rlResult.retryAfterMs };
}

Token Exchange

If the code is valid and not expired, the Gateway generates a long-lived device token and returns it. The code is immediately invalidated to prevent reuse.

// On successful pairing, the GatewayAuthResult includes the device token
const result: GatewayAuthResult = {
  ok: true,
  method: 'device-token',
  user: 'device:' + deviceId,
};
// The device token is returned in the response payload
// The client stores it securely for future connections

Subsequent Connections

For all future connections, the device includes its token in ConnectParams.connectAuth. The Gateway validates the token through authorizeGatewayConnect() without needing any user interaction.

Pairing State Machine

stateDiagram-v2
    [*] --> CodeGenerated: Trusted device requests code
    CodeGenerated --> CodePending: Code displayed to user
    CodePending --> CodeSubmitted: New device submits code
    CodePending --> CodeExpired: TTL exceeded
    CodeSubmitted --> RateLimitCheck: Check device-token scope
    RateLimitCheck --> Rejected: Rate limited
    RateLimitCheck --> Validating: Allowed
    Validating --> Paired: Code valid
    Validating --> Failed: Code invalid/expired
    Failed --> CodePending: User retries
    Paired --> TokenIssued: Generate device token
    TokenIssued --> [*]: Device stores token
    CodeExpired --> [*]: Pairing abandoned
    Rejected --> [*]: Too many attempts

Edge Cases & Error Handling

ScenarioBehaviorUser Impact
Code expiredReturn {ok: false, reason: "code-expired"}Must request new code
Code already usedReturn {ok: false, reason: "code-consumed"}Must request new code
Rate limitedReturn {ok: false, rateLimited: true}Wait for lockout to expire
Invalid code formatReturn {ok: false, reason: "invalid-code"}Re-enter code correctly
Token revokedReturn {ok: false, reason: "token-revoked"}Must re-pair device
Gateway restartPersistent tokens survive; in-flight codes lostComplete pending pairings only

Configuration

ParameterTypeDefaultDescription
pairing.codeTtlMsnumber300000Pairing code TTL (5 min)
pairing.codeLengthnumber6Pairing code character length
rateLimit.maxAttemptsnumber10Max pairing attempts per window
rateLimit.lockoutMsnumber300000Lockout after max attempts

Directory Map

Source files for this topic
src/gateway/
├── auth.ts                    ← authorizeGatewayConnect, device-token method handler
├── auth-rate-limit.ts         ← AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN
├── credentials.ts             ← Device token storage and retrieval
└── protocol/
    └── index.ts               ← ConnectParams (includes device token field)