Recipes← all recipesView on GitHub

Verify a webhook signature

Voicewebhook signature verification

The gate refuses, with 403 and before any route runs, a request whose signature header does not match hex(HMAC(signing_key, url + raw_body)). X-Signalwire-SHA256-Signature decides when present; otherwise X-Signalwire-Signature, the SHA-1 one, does.

securitywebhooks

The claim

SignalWire signs the requests it makes to your webhooks. The SWML webhook security guide documents two headers. X-Signalwire-Signature is “HMAC-SHA1, hex encoded” and arrives on every signed request. X-Signalwire-SHA256-Signature is “HMAC-SHA256, hex encoded” and arrives on call requests. The signed payload is “the request URL concatenated directly with the raw request body, with no separator”, so the formula is hex(HMAC(signing_key, url + raw_body)). You find the key in the Dashboard under API Credentials, as Signing Key (https://signalwire.com/docs/swml/guides/webhook-security).

Why it holds

The check runs in a Flask before_request hook, so it runs before routing. A request that fails it never reaches a route handler, not even a 404.

How it works

def expected(key, url, raw_body, digest):
    return hmac.new(key.encode(), url.encode() + raw_body, digest).hexdigest()

def verify(headers, url, raw_body, key=None):
    key = key or SIGNING_KEY
    for header, digest in DIGESTS.items():          # SHA-256 first, then SHA-1
        if header in headers:                       # presence selects the digest
            sent = headers[header]
            if not HEX.fullmatch(sent):             # not hex: refuse, do not compare
                return False
            return hmac.compare_digest(sent, expected(key, url, raw_body, digest))
    return False

@app.before_request
def gate():
    url = WEBHOOK_URL + ("?" + request.query_string.decode() if request.query_string else "")
    if not verify(request.headers, url, request.get_data()):
        abort(403)

Two choices matter. The URL comes from WEBHOOK_URL, the address you gave SignalWire, not from the request. A tunnel or a proxy can present Flask with a different host, and the platform signed the one you configured. The guide says the URL includes the query string, so the hook appends the request’s. And hmac.compare_digest is the comparison Python’s hmac documentation describes as “designed to prevent timing analysis”. When both headers arrive, presence of the SHA-256 header selects it. An empty or wrong SHA-256 then fails rather than falling back to SHA-1.

Limitations

The verifier signs with its own key, so it proves the arithmetic and the gate, not that a given production request came from SignalWire. That proof is the signature on your real traffic against your real key.

The check is an HMAC over the URL and body, so it says the sender held the key and the body is unchanged. It says nothing about when the request was made: the verifier sends one valid request twice and the gate serves both. Add your own check against replay, such as refusing a call_id you have already served.

The guide says the URL “excludes basic auth credentials on call requests” and includes them on messaging requests “as configured”. Set WEBHOOK_URL to match the kind of webhook you serve.

What to change first

Change the separator: sign over url + "\n" + raw_body in expected() and run the verifier. Every served request becomes a 403 and the “a separator” case is served instead. The formula is the platform’s, not yours.