Twilio ConversationRelay の WebSocket 通信の署名検証で詰まった話
はじめに
音声 AI ボットの検証作業で Twilio ConversationRelay を自前の WebSocket サーバーで受ける実装をしていたところ、WebSocket の Upgrade リクエストの署名検証だけが通らず、通話が繰り返しエラーで切断される現象に遭遇しました。POST webhook 向けに組み立てた URL の作り方を、そのまま Upgrade リクエストの署名検証にも流用したことが原因でした。
署名対象の URL を https:// に変換して計算すると、Twilio から届く実際の署名と一致しませんでした。TwiML の <ConversationRelay url="wss://..."> に指定したとおり、wss:// のスキームのまま計算する必要がありました。
ConversationRelay の WebSocket 署名検証とは
ConversationRelay は、Twilio の音声通話を WebSocket で自分のサーバーへつなぐしくみです。サーバー側では、TwiML の <Connect><ConversationRelay> に応じて Twilio から届く WebSocket の Upgrade リクエストを受け付けます。Twilio は、接続元を検証するため、この Upgrade リクエストにも X-Twilio-Signature を検証するよう案内しています。
検証環境
- WebSocket サーバー: プレーンな Node.js +
ws+twilioパッケージ - Node.js ランタイム: v24.18.0
- デプロイ先: Vercel Functions
対象読者
- Twilio ConversationRelay を自前のサーバーで受けようとしている方
- WebSocket の Upgrade リクエストに
X-Twilio-Signatureの検証を実装しようとしている方 - 原因のわからない署名検証エラーに困っている方
参考
- ConversationRelay Onboarding (公式ドキュメント)
- Webhooks Security (公式ドキュメント)
- ConversationRelay (TwiML リファレンス)
事象: 署名検証エラーで通話が切断される
WebSocket の Upgrade リクエストに対する署名検証を実装しました。POST webhook 向けに組み立てていた requestUrl = PUBLIC_BASE_URL + req.url (PUBLIC_BASE_URL は https:// で始まる) という URL の組み立て方を、そのまま流用しました。
実際に電話をかけると、通話が error 64102 (Unable to connect to websocket URL) で切断されました。着信音は鳴りますが、TwiML の応答後に接続が確立しません。
サーバー側のログを見ると、/voice (POST) は署名検証を通過して正常処理まで進んでいましたが、/ws へのアップグレードリクエストだけ invalid_signature で 403 を返していました。同じ TWILIO_AUTH_TOKEN を使っているのに POST 側だけ通っていたのが、調査の手がかりになりました。
調査: 実際の署名を突き合わせる
デバッグ用のログを一時的に追加し、実際に Twilio から届いた署名の値と、サーバー側で計算した期待値を突き合わせたところ、両者は一致しませんでした。
そこで、ローカルで Node.js のスクリプトを書き、複数の URL 候補について HMAC-SHA1 で署名を計算し、実際に受信した署名と突き合わせました。
const crypto = require('crypto');
function sign(url, params = {}) {
const data = Object.keys(params).sort().reduce((acc, key) => acc + key + params[key], url);
return crypto.createHmac('sha1', AUTH_TOKEN).update(Buffer.from(data, 'utf-8')).digest('base64');
}
const candidates = [
'https://example.vercel.app/ws',
'wss://example.vercel.app/ws',
'https://example.vercel.app/ws/',
'https://example.vercel.app:443/ws',
];
for (const url of candidates) {
console.log(url, sign(url) === RECEIVED_SIGNATURE ? 'MATCH' : 'no match');
}
wss:// のスキームのまま計算した候補だけが、実際に受信した署名と一致しました。
原因: 署名対象 URL のスキーム
TwiML の <ConversationRelay url="wss://..."> に指定した URL が、そのまま署名の計算対象になっていました。POST webhook の検証で使う https:// への変換は、WebSocket の Upgrade リクエストには当てはまりませんでした。
対策: 修正後のコード
署名対象 URL のスキームだけを https:// から wss:// に変更し、再デプロイしました。
function validateGetRequest(req) {
const signature = req.headers['x-twilio-signature'];
const requestUrl = PUBLIC_BASE_URL.replace(/^http/, 'ws') + req.url;
return twilio.validateRequest(AUTH_TOKEN, signature, requestUrl, {});
}
実際の通話で setup イベントを正常に受信できることを確認しました。
まとめ
Twilio ConversationRelay の WebSocket 接続に自前で署名検証を実装したところ、POST webhook 向けの URL の組み立て方をそのまま流用したことが原因で、Upgrade リクエストの検証だけが通らず、通話が繰り返しエラーで切断されました。原因は、TwiML に指定した wss:// のスキームのまま署名対象 URL を計算する必要があったことです。
ConversationRelay の WebSocket 接続に X-Twilio-Signature の検証を自前で実装する場合は、POST webhook 用のコードをそのまま流用せず、署名対象 URL のスキームを確認することをおすすめします。




