JuliusBrussee/caveman · error · Error

device login failed: ${errorCode}

Error message

device login failed: ${errorCode}

What it means

While polling the token endpoint, the OAuth device grant can return an error field. authorization_pending and slow_down are expected and handled; any other error code (access_denied, expired_token, invalid_client, etc.) is fatal and thrown with the raw code embedded.

Source

Thrown at packages/device-auth/src/index.ts:168

        async acknowledge() {
          if (acknowledged) return;
          await acknowledge({
            baseURL,
            client: options.client,
            code,
            credentials,
            fetcher,
            ...(options.signal === undefined ? {} : { signal: options.signal }),
            sleep: wait,
          });
          acknowledged = true;
        },
      };
    }
    const errorCode = typeof payload.error === "string" ? payload.error : "";
    if (errorCode === "slow_down") intervalMs = nextDevicePollIntervalMs(intervalMs, errorCode);
    else if (errorCode !== "" && errorCode !== "authorization_pending") {
      throw new Error(`device login failed: ${errorCode}`);
    }
    await wait(Math.max(intervalMs, 200));
  }
  throw new Error("device login timed out before approval");
}

View on GitHub (pinned to df2ccd85c9)

Solutions

  1. Read the embedded code: access_denied means the user declined — restart the flow and approve the request.
  2. If expired_token, restart the device flow to get a fresh device code.
  3. Check the client identifier sent to /auth/device/code matches a registered client (invalid_client).
  4. Verify the account/tenant permits device authorization (org policy may deny it, surfacing as access_denied).
  5. If the code is unexpected, compare it against the OAuth device-grant error registry to find the server-side cause.

Example fix

// before (user denied, then blindly retrying the same flow)
try { await runCavemanDeviceFlow(opts); } catch { await runCavemanDeviceFlow(opts); }
// after
try { await runCavemanDeviceFlow(opts); } catch (e) {
  if (String(e).includes("access_denied")) prompt("You must approve the login to continue");
  await runCavemanDeviceFlow(opts); // restart with fresh code after explaining
}
Defensive patterns

Strategy: try-catch

Try / catch

try {
  await runCavemanDeviceFlow(options);
} catch (e) {
  if (e instanceof Error && e.message.startsWith("device login failed: ")) {
    const code = e.message.slice("device login failed: ".length);
    if (code === "access_denied") console.error("Login was denied — approve the request and retry");
    else if (code === "expired_token") console.error("Code expired — restarting flow");
    else throw e;
  } else throw e;
}

Prevention

When it happens

Trigger: The token endpoint responds with a JSON payload whose error is a string other than "authorization_pending" or "slow_down" — most commonly access_denied (user rejected) or expired_token.

Common situations: User clicks 'Cancel'/'Deny' on the verification page (access_denied); the device code expired server-side between polls (expired_token); client credentials rejected (invalid_client) after a client-id change; account/tenant restrictions blocking the grant.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@df2ccd85c9 (2026-08-31). Data as JSON: /api/errors/48b1e8d4252fefef. Report an issue: GitHub.