Skip to content

[Bug]: Control UI "device signature invalid" — token field mismatch between client signing and server #39667

@reece454

Description

@reece454

Bug type

Regression (worked before, now fails)

Summary

Bug Report: Control UI "device signature invalid" — token field mismatch between client signing and server verification

Environment

  • OpenClaw version: 2026.3.7-beta.1
  • Browser: Chrome (secure context, crypto.subtle available)
  • Auth mode: token

Summary

The Control UI's device signature verification always fails when using shared token authentication (auth.mode: "token"), because the client signs the payload with one token value while the server reconstructs the payload with a different token value for verification. This creates a deadlock:

  • dangerouslyDisableDeviceAuth: false (default) → "device signature invalid", cannot connect
  • dangerouslyDisableDeviceAuth: true (workaround) → connects, but disconnects on every page refresh because no deviceToken is ever issued

Steps to Reproduce

  1. Configure gateway with shared token auth:
    {
      "gateway": {
        "auth": { "mode": "token", "token": "<shared-token>" },
        "controlUi": {}
      }
    }
  2. Access the Control UI dashboard via HTTPS (e.g., Codespaces port forwarding)
  3. Enter the shared gateway token and click "Connect"
  4. Observe: "device signature invalid" error

Root Cause Analysis

The mismatch

The signed payload includes a token field. The client and server use different values for this field:

Code location Token value used
Client signs with ui/src/ui/gateway.ts line 247 deviceToken ?? null (per-device JWT from localStorage, or null on first connect)
Server verifies with src/gateway/server/ws-connection/message-handler.ts lines 184, 200 auth.token ?? auth.deviceToken ?? null (shared gateway token, since auth.token is always set)

Detailed trace

Client side (ui/src/ui/gateway.ts, sendConnect() method):

// Line 208: authToken = shared gateway token from config/URL
let authToken = this.opts.token;

// Lines 219-225: auth object sent to server uses the SHARED token
const auth = authToken || this.opts.password
  ? { token: authToken, password: this.opts.password }
  : undefined;

// Lines 240-249: BUT the signed payload uses deviceToken (different value!)
const payload = buildDeviceAuthPayload({
  // ...
  token: deviceToken ?? null,   // ← signs with deviceToken, NOT authToken
  nonce,
});

Server side (src/gateway/server/ws-connection/message-handler.ts, resolveDeviceSignaturePayloadVersion()):

// Lines 184, 200: server reconstructs payload using auth.token (the shared token)
token: params.connectParams.auth?.token ?? params.connectParams.auth?.deviceToken ?? null,
//     ↑ this resolves to the SHARED gateway token, because client sends auth.token = shared token

Payload format (src/gateway/device-auth.ts lines 20-34):

v2|{deviceId}|{clientId}|{clientMode}|{role}|{scopes}|{signedAtMs}|{token}|{nonce}

Why the signatures never match

Scenario 1: First connect (no cached deviceToken)

Client signs:  v2|device123|...|         |nonce    (token = null → empty string)
Server builds: v2|device123|...|5d183ea5…|nonce    (token = shared gateway token)
                                 ^^^^^^^^
                                 MISMATCH → "device signature invalid"

Scenario 2: Reconnect with cached deviceToken

Client signs:  v2|device123|...|eyJhbGci…|nonce    (token = deviceToken JWT)
Server builds: v2|device123|...|5d183ea5…|nonce    (token = shared gateway token)
                                 ^^^^^^^^
                                 MISMATCH → "device signature invalid"

In both cases, auth.token (shared token) takes precedence in the server's ?? chain, but the client never signs with the shared token.

Impact: The dangerouslyDisableDeviceAuth deadlock

Because device signature verification is broken with shared token auth, users must set dangerouslyDisableDeviceAuth: true. But this creates a secondary problem:

connect-policy.ts line 31:

device: dangerouslyDisableDeviceAuth ? null : params.deviceRaw,

When enabled, the server discards the device identity entirely, which means:

  • No pairing request is created
  • No deviceToken is issued in hello-ok response
  • The browser cannot persist any auth credential in localStorage
  • Every page refresh loses the in-memory shared token → immediate disconnect

storage.ts lines 60-61:

// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
token: defaults.token,

The shared gateway token is intentionally not persisted in localStorage (security by design), so on page refresh it's gone. Without a deviceToken fallback, the user must re-enter the token every time.

Expected Behavior

The client and server should use the same token value when building the signature payload. Either:

  1. The client should sign with the shared token (matching what the server reconstructs), or
  2. The server should reconstruct the payload using the device token (matching what the client signs), or
  3. The client should send the device token in auth.deviceToken so the server's ?? chain falls through correctly

Suggested Fix

In ui/src/ui/gateway.ts, the auth object construction (lines 219-225) should include deviceToken when available:

const auth = authToken || this.opts.password || deviceToken
  ? {
      token: authToken,
      deviceToken: deviceToken,     // ← ADD: send deviceToken separately
      password: this.opts.password,
    }
  : undefined;

This way, on the server side:

  • auth.token = shared gateway token (for authentication)
  • auth.deviceToken = the per-device JWT (for signature payload reconstruction)
  • When auth.token is present, server uses it for auth; when reconstructing the signed payload, server should prefer auth.deviceToken for the token field

Alternatively, align the client signing to use the same value the server expects:

// In buildDeviceAuthPayload call:
token: authToken ?? deviceToken ?? null,   // sign with shared token if available

Gateway Config (for reference)

{
  "gateway": {
    "port": 18789,
    "mode": "local",
    "bind": "lan",
    "controlUi": {
      "dangerouslyAllowHostHeaderOriginFallback": true,
      "allowInsecureAuth": true,
      "dangerouslyDisableDeviceAuth": true
    },
    "auth": {
      "mode": "token",
      "token": "<redacted>"
    },
    "trustedProxies": ["0.0.0.0/0"]
  }
}

Steps to reproduce

Bug Report: Control UI "device signature invalid" — token field mismatch between client signing and server verification

Environment

  • OpenClaw version: 2026.3.7-beta.1
  • Platform: GitHub Codespaces (Linux)
  • Access method: Codespaces port forwarding (https://<codespace>-18789.app.github.dev)
  • Browser: Chrome (secure context, crypto.subtle available)
  • Auth mode: token

Summary

The Control UI's device signature verification always fails when using shared token authentication (auth.mode: "token"), because the client signs the payload with one token value while the server reconstructs the payload with a different token value for verification. This creates a deadlock:

  • dangerouslyDisableDeviceAuth: false (default) → "device signature invalid", cannot connect
  • dangerouslyDisableDeviceAuth: true (workaround) → connects, but disconnects on every page refresh because no deviceToken is ever issued

Steps to Reproduce

  1. Configure gateway with shared token auth:
    {
      "gateway": {
        "auth": { "mode": "token", "token": "<shared-token>" },
        "controlUi": {}
      }
    }
  2. Access the Control UI dashboard via HTTPS (e.g., Codespaces port forwarding)
  3. Enter the shared gateway token and click "Connect"
  4. Observe: "device signature invalid" error

Root Cause Analysis

The mismatch

The signed payload includes a token field. The client and server use different values for this field:

Code location Token value used
Client signs with ui/src/ui/gateway.ts line 247 deviceToken ?? null (per-device JWT from localStorage, or null on first connect)
Server verifies with src/gateway/server/ws-connection/message-handler.ts lines 184, 200 auth.token ?? auth.deviceToken ?? null (shared gateway token, since auth.token is always set)

Detailed trace

Client side (ui/src/ui/gateway.ts, sendConnect() method):

// Line 208: authToken = shared gateway token from config/URL
let authToken = this.opts.token;

// Lines 219-225: auth object sent to server uses the SHARED token
const auth = authToken || this.opts.password
  ? { token: authToken, password: this.opts.password }
  : undefined;

// Lines 240-249: BUT the signed payload uses deviceToken (different value!)
const payload = buildDeviceAuthPayload({
  // ...
  token: deviceToken ?? null,   // ← signs with deviceToken, NOT authToken
  nonce,
});

Server side (src/gateway/server/ws-connection/message-handler.ts, resolveDeviceSignaturePayloadVersion()):

// Lines 184, 200: server reconstructs payload using auth.token (the shared token)
token: params.connectParams.auth?.token ?? params.connectParams.auth?.deviceToken ?? null,
//     ↑ this resolves to the SHARED gateway token, because client sends auth.token = shared token

Payload format (src/gateway/device-auth.ts lines 20-34):

v2|{deviceId}|{clientId}|{clientMode}|{role}|{scopes}|{signedAtMs}|{token}|{nonce}

Why the signatures never match

Scenario 1: First connect (no cached deviceToken)

Client signs:  v2|device123|...|         |nonce    (token = null → empty string)
Server builds: v2|device123|...|5d183ea5…|nonce    (token = shared gateway token)
                                 ^^^^^^^^
                                 MISMATCH → "device signature invalid"

Scenario 2: Reconnect with cached deviceToken

Client signs:  v2|device123|...|eyJhbGci…|nonce    (token = deviceToken JWT)
Server builds: v2|device123|...|5d183ea5…|nonce    (token = shared gateway token)
                                 ^^^^^^^^
                                 MISMATCH → "device signature invalid"

In both cases, auth.token (shared token) takes precedence in the server's ?? chain, but the client never signs with the shared token.

Impact: The dangerouslyDisableDeviceAuth deadlock

Because device signature verification is broken with shared token auth, users must set dangerouslyDisableDeviceAuth: true. But this creates a secondary problem:

connect-policy.ts line 31:

device: dangerouslyDisableDeviceAuth ? null : params.deviceRaw,

When enabled, the server discards the device identity entirely, which means:

  • No pairing request is created
  • No deviceToken is issued in hello-ok response
  • The browser cannot persist any auth credential in localStorage
  • Every page refresh loses the in-memory shared token → immediate disconnect

storage.ts lines 60-61:

// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
token: defaults.token,

The shared gateway token is intentionally not persisted in localStorage (security by design), so on page refresh it's gone. Without a deviceToken fallback, the user must re-enter the token every time.

Expected Behavior

The client and server should use the same token value when building the signature payload. Either:

  1. The client should sign with the shared token (matching what the server reconstructs), or
  2. The server should reconstruct the payload using the device token (matching what the client signs), or
  3. The client should send the device token in auth.deviceToken so the server's ?? chain falls through correctly

Suggested Fix

In ui/src/ui/gateway.ts, the auth object construction (lines 219-225) should include deviceToken when available:

const auth = authToken || this.opts.password || deviceToken
  ? {
      token: authToken,
      deviceToken: deviceToken,     // ← ADD: send deviceToken separately
      password: this.opts.password,
    }
  : undefined;

This way, on the server side:

  • auth.token = shared gateway token (for authentication)
  • auth.deviceToken = the per-device JWT (for signature payload reconstruction)
  • When auth.token is present, server uses it for auth; when reconstructing the signed payload, server should prefer auth.deviceToken for the token field

Alternatively, align the client signing to use the same value the server expects:

// In buildDeviceAuthPayload call:
token: authToken ?? deviceToken ?? null,   // sign with shared token if available

Current Workaround

A Tampermonkey userscript that injects #token=<shared-token> into the URL on every page load (@run-at document-start), before the app's <script type="module"> executes. The app reads the token from the URL hash in applySettingsFromUrl(), re-establishing the connection on each refresh.

Gateway Config (for reference)

{
  "gateway": {
    "port": 18789,
    "mode": "local",
    "bind": "lan",
    "controlUi": {
      "dangerouslyAllowHostHeaderOriginFallback": true,
      "allowInsecureAuth": true,
      "dangerouslyDisableDeviceAuth": true
    },
    "auth": {
      "mode": "token",
      "token": "<redacted>"
    },
    "trustedProxies": ["0.0.0.0/0"]
  }
}

Expected behavior

The client and server should use the same token value when building the signature payload. Either:

  1. The client should sign with the shared token (matching what the server reconstructs), or
  2. The server should reconstruct the payload using the device token (matching what the client signs), or
  3. The client should send the device token in auth.deviceToken so the server's ?? chain falls through correctly

Actual behavior

dangerouslyDisableDeviceAuth: false=》device signature invalid
dangerouslyDisableDeviceAuth: true=》"device identity required" on every page refresh, must re-enter gateway token to reconnect

OpenClaw version

2026.3.7-beta.1

Operating system

Ubuntu 24.04.3 LTS

Install method

No response

Logs, screenshots, and evidence

Impact and severity

No response

Additional information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingregressionBehavior that previously worked and now fails

    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