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
- Configure gateway with shared token auth:
{
"gateway": {
"auth": { "mode": "token", "token": "<shared-token>" },
"controlUi": {}
}
}
- Access the Control UI dashboard via HTTPS (e.g., Codespaces port forwarding)
- Enter the shared gateway token and click "Connect"
- 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:
- The client should sign with the shared token (matching what the server reconstructs), or
- The server should reconstruct the payload using the device token (matching what the client signs), or
- 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
- Configure gateway with shared token auth:
{
"gateway": {
"auth": { "mode": "token", "token": "<shared-token>" },
"controlUi": {}
}
}
- Access the Control UI dashboard via HTTPS (e.g., Codespaces port forwarding)
- Enter the shared gateway token and click "Connect"
- 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:
- The client should sign with the shared token (matching what the server reconstructs), or
- The server should reconstruct the payload using the device token (matching what the client signs), or
- 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:
- The client should sign with the shared token (matching what the server reconstructs), or
- The server should reconstruct the payload using the device token (matching what the client signs), or
- 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
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
crypto.subtleavailable)tokenSummary
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 connectdangerouslyDisableDeviceAuth: true(workaround) → connects, but disconnects on every page refresh because nodeviceTokenis ever issuedSteps to Reproduce
{ "gateway": { "auth": { "mode": "token", "token": "<shared-token>" }, "controlUi": {} } }Root Cause Analysis
The mismatch
The signed payload includes a
tokenfield. The client and server use different values for this field:ui/src/ui/gateway.tsline 247deviceToken ?? null(per-device JWT from localStorage, or null on first connect)src/gateway/server/ws-connection/message-handler.tslines 184, 200auth.token ?? auth.deviceToken ?? null(shared gateway token, sinceauth.tokenis always set)Detailed trace
Client side (
ui/src/ui/gateway.ts,sendConnect()method):Server side (
src/gateway/server/ws-connection/message-handler.ts,resolveDeviceSignaturePayloadVersion()):Payload format (
src/gateway/device-auth.tslines 20-34):Why the signatures never match
Scenario 1: First connect (no cached deviceToken)
Scenario 2: Reconnect with cached deviceToken
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
dangerouslyDisableDeviceAuthdeadlockBecause device signature verification is broken with shared token auth, users must set
dangerouslyDisableDeviceAuth: true. But this creates a secondary problem:connect-policy.tsline 31:When enabled, the server discards the device identity entirely, which means:
deviceTokenis issued inhello-okresponsestorage.tslines 60-61:The shared gateway token is intentionally not persisted in localStorage (security by design), so on page refresh it's gone. Without a
deviceTokenfallback, 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:
auth.deviceTokenso the server's??chain falls through correctlySuggested Fix
In
ui/src/ui/gateway.ts, theauthobject construction (lines 219-225) should includedeviceTokenwhen available:This way, on the server side:
auth.token= shared gateway token (for authentication)auth.deviceToken= the per-device JWT (for signature payload reconstruction)auth.tokenis present, server uses it for auth; when reconstructing the signed payload, server should preferauth.deviceTokenfor the token fieldAlternatively, align the client signing to use the same value the server expects:
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
https://<codespace>-18789.app.github.dev)crypto.subtleavailable)tokenSummary
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 connectdangerouslyDisableDeviceAuth: true(workaround) → connects, but disconnects on every page refresh because nodeviceTokenis ever issuedSteps to Reproduce
{ "gateway": { "auth": { "mode": "token", "token": "<shared-token>" }, "controlUi": {} } }Root Cause Analysis
The mismatch
The signed payload includes a
tokenfield. The client and server use different values for this field:ui/src/ui/gateway.tsline 247deviceToken ?? null(per-device JWT from localStorage, or null on first connect)src/gateway/server/ws-connection/message-handler.tslines 184, 200auth.token ?? auth.deviceToken ?? null(shared gateway token, sinceauth.tokenis always set)Detailed trace
Client side (
ui/src/ui/gateway.ts,sendConnect()method):Server side (
src/gateway/server/ws-connection/message-handler.ts,resolveDeviceSignaturePayloadVersion()):Payload format (
src/gateway/device-auth.tslines 20-34):Why the signatures never match
Scenario 1: First connect (no cached deviceToken)
Scenario 2: Reconnect with cached deviceToken
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
dangerouslyDisableDeviceAuthdeadlockBecause device signature verification is broken with shared token auth, users must set
dangerouslyDisableDeviceAuth: true. But this creates a secondary problem:connect-policy.tsline 31:When enabled, the server discards the device identity entirely, which means:
deviceTokenis issued inhello-okresponsestorage.tslines 60-61:The shared gateway token is intentionally not persisted in localStorage (security by design), so on page refresh it's gone. Without a
deviceTokenfallback, 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:
auth.deviceTokenso the server's??chain falls through correctlySuggested Fix
In
ui/src/ui/gateway.ts, theauthobject construction (lines 219-225) should includedeviceTokenwhen available:This way, on the server side:
auth.token= shared gateway token (for authentication)auth.deviceToken= the per-device JWT (for signature payload reconstruction)auth.tokenis present, server uses it for auth; when reconstructing the signed payload, server should preferauth.deviceTokenfor the token fieldAlternatively, align the client signing to use the same value the server expects:
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 inapplySettingsFromUrl(), 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:
auth.deviceTokenso the server's??chain falls through correctlyActual 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