#Voice-call plugin: "invalid token" with Cloudflare Tunnel

1 messages · Page 1 of 1 (latest)

static glade
#

Voice-call plugin: "invalid token" with Cloudflare Tunnel

Setup: Twilio + voice-call plugin + Cloudflare named tunnel (not ngrok)

publicUrl set to https://voice.mydomain.com/voice/webhook (NOT GIVING OUT MY REAL INFO HERE 😉
skipSignatureVerification: true
streaming.enabled: true

One-way notify calls work perfectly — HTTP webhooks go through the tunnel fine, Twilio gets TwiML back, I hear audio. So the tunnel and webhook path are solid.

The issue is only with two-way conversation calls (media streaming). The WebSocket connects successfully through the tunnel:

[voice-call] WebSocket upgrade for media stream
[MediaStream] Twilio connected
[MediaStream] Stream started: [streamSid] (call: [callSid])
But then the plugin immediately rejects it:

[voice-call] Rejecting media stream: invalid token for [callSid]
[MediaStream] Rejecting stream for unknown call: [callSid]
Twilio error 31921 (remote server closed WebSocket). Call ends instantly (0 duration). Happens for both inbound and outbound calls.

I checked the plugin source — getStreamConnectXml in twilio.ts generates the TwiML with no token parameter in the Stream URL (just wss://voice.mydomain.com/voice/stream). Yet the runtime rejects with "invalid token", so the validation seems to happen somewhere outside the visible TypeScript source.

Is there a config flag to disable stream token validation? Or is this a known issue with external tunnels?

OpenClaw 2026.2.3-1, Windows, Node 24

spice crystalBOT
# static glade Voice-call plugin: "invalid token" with Cloudflare Tunnel Setup: Twilio + voice...

That “invalid token” check is in the plugin, and there’s currently no config flag to disable it (it was added as a security hardening so random internet clients can’t open /voice/stream).

Where the token is supposed to come from

For Twilio streaming calls, the plugin generates a per-call token and (in current code) appends it to the WebSocket URL as a query param:

  • TwiML should end up with something like:
    wss://voice.mydomain.com/voice/stream?token=

  • On the server side, MediaStreamHandler extracts token from the upgrade request URL querystring.
  • Then TwilioProvider.isValidStreamToken(callSid, token) validates it. If missing/mismatched ⇒ you get exactly your log + Twilio 31921.

So the failure basically means: the gateway isn’t seeing ?token=... on the incoming WS upgrade request (or the gateway instance that receives the WS doesn’t have the matching in-memory token).

The two most common causes with tunnels/proxies

  1. Query string not making it through on the WS upgrade
  • Some proxy/tunnel configs (or transform rules) can drop/normalize query params for WS routes.
  • Cloudflare usually forwards them, but if you have any URL rewrite/transform rules on that hostname/path, this is the first thing to suspect.
#
  1. More than one backend/connector (token is in-memory)
  • The token lives in-memory in the running Gateway/plugin process.
  • If your TwiML webhook hits one instance/connector and the WS hits another, token validation will fail.
  • This can happen if you run multiple cloudflared connectors for the same named tunnel (HA), or accidentally have two OpenClaw gateways running.

What I’d check next (fast, high-signal)

  1. In Twilio Console for a failed call, inspect the actual TwiML returned and confirm the <Stream url="..."> includes ?token=... (anonymize the token, but keep “does it exist?” + the shape of the URL).
  2. Enable cloudflared request logging (or whatever logging you have at the edge) and confirm the WS request line reaching your origin includes ?token=....
  3. Confirm you only have one Gateway process and (ideally) one cloudflared connector serving that tunnel while debugging.

If you paste these (sanitized), I can tell you exactly which branch you’re in

  • Your cloudflared ingress config for voice.mydomain.com (just the relevant hostname/path/service bits)
  • The Twilio-generated TwiML snippet containing <Connect><Stream url="...">

If this ends up being “Cloudflare drops query on WS upgrade in this config”, the “real fix” is to adjust tunnel/proxy config so the querystring survives (rather than disabling validation).

static glade
#

Thanks for the detailed breakdown. Here's what I found:

  1. TwiML does include the token:

<Response>
<Connect>
<Stream url="wss://voice.mydomain.com/voice/stream?token=[redacted-22-chars]" />
</Connect>
</Response>
Confirmed via Twilio call events API — token is present in the Stream URL.

  1. cloudflared config (simplified, no transform rules):

ingress:

  1. Single gateway process, single cloudflared process — confirmed, no HA/duplicate issue.

  2. WebSocket upgrade does go through — I tested with a raw WS client through the tunnel and it connects fine (101 upgrade). I also confirmed locally that query params reach the server.

  3. Timeline from logs:

WebSocket upgrade for media stream ← upgrade succeeds
Twilio connected ← WS open
Stream started: [streamSid] (call: [callSid]) ← Twilio sends start msg
Rejecting media stream: invalid token for [callSid] ← rejected
Rejecting stream for unknown call: [callSid]
So the WS connects, Twilio sends its start event with the callSid, and THEN the token check fails. The token is definitely in the TwiML URL — so either Cloudflare is stripping ?token= from the WS upgrade request specifically, or the token lookup is failing internally (maybe stored against the internal callId but looked up by Twilio callSid?).

Any way to get more verbose logging from the token validation step?

spice crystalBOT
# static glade Thanks for the detailed breakdown. Here's what I found: 1. TwiML does include t...

No config flag for “more verbose token validation” right now — that check is just:

  • MediaStreamHandler extracts token from the WS upgrade request URL
  • TwilioProvider.isValidStreamToken(callSid, token) compares against an in‑memory map
  • on failure it only logs the one-line warning you’re seeing

So if you want to know which part is failing (token missing vs expected missing vs mismatch), you currently have to instrument the plugin.

Quick way to instrument (safe, minimal, no full tokens)

Since openclaw plugins install @openclaw/voice-call unpacks into ~/.openclaw/extensions/voice-call/, you can edit it locally on Windows at:

C:\Users\<you>\.openclaw\extensions\voice-call\src\...

1) Log what Twilio’s WS upgrade URL actually looks like

Edit:

src/webhook.ts in the server.on("upgrade", ...) handler, right before handleUpgrade(...):

#
console.log("[voice-call] WS upgrade url=", request.url, "host=", request.headers.host);

This will immediately tell us if Twilio+Cloudflare is delivering /voice/stream?token=... or just /voice/stream.

2) Log what token was parsed (presence + length only)

Edit:

src/media-stream.ts inside getStreamToken(...):

console.log(
  "[MediaStream] token parse",
  "url=", request.url,
  "host=", request.headers.host,
#
);

const token = url.searchParams.get("token") ?? undefined;
console.log("[MediaStream] token present=", !!token, "len=", token?.length ?? 0);
return token;

3) Log whether the expected token exists for that CallSid

Edit:

src/providers/twilio.ts inside isValidStreamToken(callSid, token?):

const expected = this.streamAuthTokens.get(callSid);
console.log(
#
  "[voice-call] token check",
  "callSid=", callSid,
  "expectedPresent=", !!expected,
  "expectedLen=", expected?.length ?? 0,
  "gotPresent=", !!token,
  "gotLen=", token?.length ?? 0,
);

Restart the Gateway, reproduce one call, then you’ll know exactly which case you’re in.

Also: turn up gateway logging so you don’t lose the prints

In your ~/.openclaw/openclaw.json set: