signature-lockWebhook Signature Verification

Learn how to configure verification for your webhook listener endpoint

Webhooks are unauthenticated HTTP callbacks by default. When Mobile Text Alerts sends an event to your listener endpoint this event can be impersonated with an identically structured POST request. This can result in fabricated delivery receipts, injecting fake opt-ins, or triggering unintended business logic. With signature verification your server can distinguish a genuine request from Mobile Text Alerts from a spoofed one.

The OWASParrow-up-right (Open Web Application Security Project) publishes the API Security Top 10arrow-up-right — a widely referenced list of the most critical security risks for APIs. Webhook signature verification maps directly to the following categories:

  • API8:2023 — Security Misconfiguration

    • Exposing an unauthenticated callback endpoint is a misconfiguration. Verifying the X-Signature header on every inbound request closes this gap.

  • API2:2023 — Broken Authentication

    • Accepting webhook payloads without authenticating their origin is a broken authentication pattern. HMAC-SHA256arrow-up-right verification gives your endpoint a cryptographically sound mechanism for confirming request sender.

How Mobile Text Alerts signs webhook requests

When registering a webhook, you are required to provide a secret value to Mobile Text Alerts. This is typically a 128-character hexadecimal string. Mobile Text Alerts stores this secret value and uses it to sign every subsequent request it delivers to your endpoint to be used for validation.

For each webhook event trigger, Mobile Text Alerts will:

  1. Serialize the request body to raw bytes.

  2. Computes HMAC-SHA256arrow-up-right(secret, raw_body).

  3. Encodes the digest as a lowercase hex string.

  4. Sets that string as the value of the X-Signature HTTP request header in requests made to your webhook listener endpoint.

Example: How a signature is created by Mobile Text Alerts

signature = HMAC-SHA256(
    key    = secret,          // the secret you provided at registration
    message = raw_body_bytes  // the exact bytes MTA will send as the POST body
)
hex_signature = lowercase_hex(signature)    

The result is placed in the X-Signature header. Mobile Text Alerts does not include a timestamp or nonce in the signed value.

How to verify a webhook request to your listener endpoint

  1. Extract the signature header from the incoming request.

  2. Read the raw request body (must be raw bytes, not parsed JSON).

  3. Compute the expected signature using your stored secret.

  4. Compare the signatures using a constant-time comparison function.

  5. Return the correct HTTP Response:

    • Signature match → 200 OK (processed)

    • Signature absent / Signature mismatch → 401 Unauthorized (do not process)

    • Payload schema error → 400 Bad Request

    • Internal server error → 500

Code Verification Examples

cURL Incoming Request Example - Local development testing

A cURL test example can be useful for local development and CI testing to send test requests without needing to trigger a real Mobile Text Alerts event.

  1. Compute the signature locally using openssl dgst -sha256 -hmac "$SECRET" — the same HMAC-SHA256 operation Mobile Text Alerts performs on its end before dispatching a real webhook. (The awk '{print $2}' strips the (stdin)= prefix that openssl dgst prepends, leaving just the lowercase hex digest.)

  2. Send it as a signed HTTP request to your endpoint with the resulting hex digest in the X-Signature header.

Example request with valid signature:

Note The -d flag sends the body exactly as the string is defined, so what gets signed and what gets sent are byte-for-byte identical.

Example: Verification of incoming requests

You will then verify the incoming request using a constant-time comparison function. It should accept a correctly signed request and reject a tampered one.

Note: Use express.raw() (or bodyParser.raw()) so that req.body is a Buffer of the original bytes. Never pass req.body through JSON.parse before computing the HMAC.

Troubleshooting

If you are having issues with the signature matching the expected result here are some common causes:

  • Signing the parsed body instead of raw bytes- If you automatically parse the incoming JSON before verification and you then re-serialise it to compute the HMAC, you may get a different byte sequence. Always read the raw request bytes before any parsing touches the body.

  • Trailing newline- This can be an issue when testing with cURL or shell scripts. echo "$BODY" appends a \n to the string before piping it to openssl, so the bytes being signed differ from the bytes being sent. Use printf '%s' to avoid this.

  • Character encoding mismatch- The HMAC must be computed over the UTF-8 byte representation of the body. If your runtime interprets the body as Latin-1 or re-encodes any characters, multi-byte characters (accented letters, emoji, non-ASCII phone number formats) will produce different bytes and a different digest.

  • Whitespace or formatting differences- Some HTTP middleware (reverse proxies, API gateways, logging) can normalize JSON in transit by pretty-printing it, stripping whitespace, or reordering keys. Any transformation between what Mobile Text Alerts sends and what you receive will break the signature.

  • Case mismatch- Mobile Text Alerts produces a lowercase hex string. If your code upper-cases either the received or computed value before comparing, they won't match. Keep both sides lowercase as a safe default.

Webhook Security Best Practices

  • Always verify request signatures for production environments.

  • Webhook endpoint is served over HTTPS only — reject HTTP.

  • Your secret should be stored in an environment variable or secrets manager. Not in source code, logged or included in error messages.

  • Raw request bytes are signed, not the parsed JSON object.

  • Signature comparison uses a constant-time function (timingSafeEqual, compare_digest, hash_equals, etc.).

  • A missing or malformed X-Signature header should return HTTP 401 immediately.

Last updated

Was this helpful?