Verify SWML request signatures

Confirm that a request for a SWML document really came from SignalWire.
View as MarkdownOpen in Claude

When you serve SWML from your own web server, anyone who learns your endpoint URL can POST to it and read back the SWML document you return. Since a SWML document can contain phone numbers, SIP credentials, and prompts, that endpoint should not answer to just anyone.

To let you check the caller, SignalWire signs every request for a SWML document with an HMAC signature derived from your project’s signing key. Verifying that signature proves the request came from SignalWire and that neither the URL nor the body was altered in transit.

This step is not optional!

For production applications it is extremely important to verify the signature, so that requests from a malicious third party are rejected instead of being served a SWML document.

Which requests are signed

Every POST SignalWire makes to fetch a SWML document from a URL you control is signed. That includes:

  • The initial fetch, when a Resource or phone number is configured with an External URL rather than a hosted script, and the fetch from your fallback URL when the primary one fails.
  • Every subsequent fetch caused by execute or transfer pointing at an external URL.
  • The same fetches on the messaging side, when a number handles inbound SMS and MMS with Messaging SWML.

Requests during a call carry two headers. Requests for a messaging document carry the SHA-1 header alone, so verify that one if your endpoint serves both.

HeaderAlgorithmSent on
X-Signalwire-SignatureHMAC-SHA1, hex encodedEvery signed request
X-Signalwire-SHA256-SignatureHMAC-SHA256, hex encodedCall requests

Both are computed over the same string: the request URL concatenated directly with the raw request body, with no separator.

signature = hex( HMAC( signing_key, url + raw_body ) )

The url is the full URL SignalWire requested, including any query string. A call request signs that URL without any basic auth credentials you embedded in it; a message request signs it exactly as you configured it, credentials included. The raw_body is the JSON payload exactly as sent — the object containing call (or message for a messaging document), vars, envs, and, when the document was reached through execute or transfer, params.

Verify against the URL you configured in the Dashboard, not the URL your framework reconstructs from the incoming request. Proxies, load balancers, and tunnels such as ngrok routinely rewrite the host or scheme, which changes the string being hashed and makes a valid signature look invalid.

Which requests are not signed

Signatures cover requests for a SWML document. They do not cover everything a document can send to your server, so an endpoint that insists on a signature will reject traffic you meant to accept.

The POST to a SWAIG function’s web_hook_url is unsigned. Protect it with HTTP basic auth: embed the credentials in the URL as username:password@url, or set web_hook_auth_user and web_hook_auth_password in SWAIG.defaults or on the individual SWAIG function. Agents built with the Server SDKs authenticate differently again — basic auth on every request, plus per-function tokens for sensitive operations, covered in Server SDK security.

The conversation summary an agent sends to post_prompt_url is unsigned when you serve your own SWML. Protect that endpoint the same way, with credentials embedded in the URL.

The request method is unsigned as well when a call runs it. Use basic auth in its URL, or send a shared secret in a custom X- header through the method’s headers parameter. Its messaging counterpart does carry the SHA-1 header.

Get your signing key

Your signing key is on the API Credentials page of your Dashboard. Click Show to reveal it. Each project has its own key, so use the key belonging to the project that serves the call.

The API Credentials page in a SignalWire Space showing the signing key

The Signing Key on the API Credentials page

You can rotate the key with the reset button on the same page. A new key takes about a minute to become active, and the page shows it to you before you confirm the reset so you can copy it into your application first.

Treat the signing key like a password: keep it in an environment variable or a secret manager, not in the source you deploy.

Verify the signature in Node

The validateRequest helper in @signalwire/web-api implements the check for you:

$npm install @signalwire/web-api

validateRequest needs the raw request body, so capture it before your JSON parser consumes it. Re-serializing the parsed object is not reliable — key order and whitespace change, and the hash changes with them.

index.js
1const express = require("express");
2const { validateRequest } = require("@signalwire/web-api");
3
4const app = express();
5
6// Keep the raw body around for signature verification.
7app.use(
8 express.json({
9 verify: (req, _res, buf) => {
10 req.rawBody = buf.toString();
11 },
12 })
13);
14
15// The public-facing URL you configured in the Dashboard.
16const WEBHOOK_URL = "https://example.ngrok.io/start";
17
18app.post("/start", (req, res) => {
19 const valid = validateRequest(
20 process.env.SIGNALWIRE_SIGNING_KEY,
21 req.headers["x-signalwire-signature"],
22 WEBHOOK_URL,
23 req.rawBody
24 );
25
26 if (!valid) {
27 return res.status(403).send("Invalid signature");
28 }
29
30 res.send(`
31 sections:
32 main:
33 - play:
34 url: 'say:Hello from SignalWire!'
35 `);
36});
37
38app.listen(3000);

Verify the signature in any language

The scheme is a plain hex HMAC, so you can implement it directly wherever a helper is not available. Compare digests with a constant-time comparison rather than string equality.

1import hmac
2import hashlib
3import os
4
5from flask import Flask, request, Response
6
7app = Flask(__name__)
8
9# The public-facing URL you configured in the Dashboard.
10WEBHOOK_URL = "https://example.ngrok.io/start"
11
12
13def signature_is_valid(url, raw_body, header):
14 expected = hmac.new(
15 os.environ["SIGNALWIRE_SIGNING_KEY"].encode(),
16 (url + raw_body).encode(),
17 hashlib.sha1, # hashlib.sha256 for X-Signalwire-SHA256-Signature
18 ).hexdigest()
19
20 return hmac.compare_digest(expected, header or "")
21
22
23@app.route("/start", methods=["POST"])
24def start():
25 raw_body = request.get_data(as_text=True)
26 header = request.headers.get("X-Signalwire-Signature")
27
28 if not signature_is_valid(WEBHOOK_URL, raw_body, header):
29 return Response("Invalid signature", status=403)
30
31 return Response(
32 """
33 sections:
34 main:
35 - play:
36 url: 'say:Hello from SignalWire!'
37 """,
38 mimetype="text/plain",
39 )

To verify the stronger header on a call request, hash the same url + raw_body string with SHA-256 and compare it against X-Signalwire-SHA256-Signature.

Confirm your endpoint rejects forgeries

With the server running and reachable at the URL you configured, POST to it yourself, without a signature:

$curl -i -X POST https://example.ngrok.io/start \
> -H "Content-Type: application/json" \
> -d '{}'

The response is 403 Forbidden, with Invalid signature as the body. Now place a call to the number pointed at that URL: this request carries a valid signature, passes the check, and the caller hears the prompt your document plays.

If the real call is rejected too, the WEBHOOK_URL in your code doesn’t match the URL SignalWire requested — an ngrok URL that changed when the tunnel restarted is the common cause. The next section covers the rest.

Troubleshoot a failing signature

A signature that never validates almost always comes down to one of these:

  • The URL does not match. Scheme, host, port, path, and query string all feed the hash. Use the exact URL configured in the Dashboard, including the query string if you configured one.
  • The body was re-serialized. Hash the bytes you received, not JSON.stringify of the parsed object.
  • The wrong project’s key. Signing keys are per project.
  • The key was just rotated. A new key needs about a minute to become active.
  • Basic auth in the URL. A call strips embedded credentials before signing, so hash the URL without them. A message signs the URL as configured, so hash it with them.

Next steps