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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions