Skip to content

[Bug]: Device token auth regression in v2026.2.14 - token priority flip in d8a2c80cd breaks non-localhost clients #17270

@milosm

Description

@milosm

Summary

Commit d8a2c80cd ("fix(gateway): prefer explicit token over stored auth") flipped the client-side token priority in src/gateway/client.ts from storedToken ?? this.opts.token to this.opts.token ?? storedToken and removed the canFallbackToShared self-healing mechanism. This causes any client that has both a stored device token and a config/env token to send the wrong token, breaking device-token auth for non-localhost connections.

This appears to be the shared root cause behind #16820, #16862, #17223, and #17233.

Steps to reproduce

  1. On v2026.2.13, configure gateway.bind=lan and pair a device (Node, CLI, or browser)
  2. Verify the connection works
  3. Upgrade to v2026.2.14
  4. Device connection fails with unauthorized: device token mismatch
  5. Downgrade to v2026.2.13 - connection works again immediately

Expected behavior

Previously paired devices should continue to authenticate via their stored device tokens after upgrading.

Actual behavior

All non-localhost device-authenticated connections fail with:

unauthorized: device token mismatch (rotate/reissue device token)

OpenClaw version

v2026.2.14

Operating system

Ubuntu 24.04 LTS (Hyper-V VM), but OS-independent - also reported on macOS (#17233), PopOS (#16820), and Linux arm64 (#17223).

Install method

npm global

Logs, screenshots, and evidence

The commit diff

d8a2c80cd changed src/gateway/client.ts in two ways:

Change 1 — Token priority flip (line ~191):
// v2026.2.13 — device token takes priority
const authToken = storedToken ?? this.opts.token ?? undefined;
const canFallbackToShared = Boolean(storedToken && this.opts.token);

// v2026.2.14 — config/env token takes priority
const authToken = this.opts.token ?? storedToken ?? undefined;
// canFallbackToShared removed

Change 2 — Self-healing fallback removed (lines ~273-278):
// v2026.2.13 — .catch() handler cleared stale device tokens
if (canFallbackToShared && this.opts.deviceIdentity) {
  clearDeviceAuthToken({
    deviceId: this.opts.deviceIdentity.deviceId,
    role,
  });
}

// v2026.2.14 — entire block deleted
// companion commit 00b7ab7db removed the now-unused clearDeviceAuthToken import



Token flow trace

How this.opts.token is populated - all common GatewayClient call sites resolve it from the shared config token:

call.ts (line 208-220)
• token resolves to: CLI --token OR OPENCLAW_GATEWAY_TOKEN env OR gateway.auth.token from config

node-host/runner.ts (line 94-96)
• token resolves to: OPENCLAW_GATEWAY_TOKEN env OR gateway.auth.token from config

tui/gateway-chat.ts (line 247-258)
• token resolves to: Same pattern

acp/server.ts (line 25-29)
• token resolves to: Same pattern

In the common local/LAN case (no explicit --token), this.opts.token = the shared config token. This is a different value from the device-specific token in device-auth.json.


How storedToken is populated:
const storedToken = this.opts.deviceIdentity
  ? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
  : null;

Reads from ~/.openclaw/identity/device-auth.json — the device-specific token issued by the server during previous successful connect.

v2026.2.13 server-side flow (working):
1. Client sends storedToken (device token) as connectParams.auth.token
2. authorizeGatewayConnect() compares it against server's shared secret → doesn't match → authOk = false
3. Fallback: verifyDeviceToken({ token: connectParams.auth.token }) → matches device token in pairing DB → authOk = true

v2026.2.14 server-side flow (broken):
1. Client sends this.opts.token (shared config token) as connectParams.auth.token
2. authorizeGatewayConnect() compares it against server's shared secret → result varies by setup:  
• Same machine, matching config: succeeds → device check skipped → bug masked
• LAN/remote, config drift, stale env var, password mode, trusted-proxy: fails → authOk = false
3. Fallback: verifyDeviceToken({ token: connectParams.auth.token }) → receives config token, not the device token → mismatch → rejected
isLocalDirectRequest() is not involved

Impact and severity

Impact and severity: High - breaks all non-localhost device auth.

Affects:

Workaround: downgrade to v2026.2.13.

Additional information

The commit's intent was valid but the implementation is too broad. The inline comment says:

Prefer explicitly provided credentials (e.g. CLI --token) over any persisted device-auth tokens.

This fixes the real case where a user passes --token my-new-token but a stale stored device token takes priority. However, this.opts.token doesn't distinguish between an explicit CLI --token and a passive config fallback — both arrive as the same field.

Suggested fixes:

  • Option A (safest): Full revert — restore storedToken ?? this.opts.token priority and the canFallbackToShared cleanup. The self-healing mechanism handled stale device tokens acceptably (clear on first failure, succeed on retry).
  • Option B (cleanest): Add an explicitToken field to GatewayClient options. Priority: explicitToken ?? storedToken ?? opts.token. Only callers receiving --token set explicitToken; config/env fallbacks use opts.token.

No test coverage: Zero unit tests cover the token priority logic in GatewayClient.sendConnect().

Related issues: #16820, #16862, #17223, #17233

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions