Securing incoming webhooks

A webhook endpoint accepts requests from outside an application's normal session flow. Treat each request as untrusted until its signature, freshness, payload and permitted business scope have been checked using the sender's documented protocol.

Review signature verification

Whitespace changes the signed message

Original request

40 bytes · UTF-8
{"id":"evt_1042","type":"order.updated"}

After reserializing

43 bytes · UTF-8
{"id": "evt_1042", "type": "order.updated"}

Three added spaces change the message supplied to the signature verifier.

Preserve the original request bytes. Verify them using the sender’s documented signing format before business processing.

Verify the exact bytes received

Read the provider's signature specification before choosing middleware. Signature schemes commonly authenticate the original request bytes, sometimes combined with a timestamp or other fields. Parsing and reserializing JSON can change those bytes even when the business values appear unchanged.

Stripe specifically requires the raw request body for signature verification. Prefer the provider's maintained verification library when available, and configure body handling accordingly. Require HTTPS and impose a request size limit before allocating unbounded memory to an unauthenticated request.

Treat the signature format as a protocol

Do not invent a generic signature parser and assume it works for every provider. Establish the signed fields, supported algorithm, encoding, key identification and comparison behavior. Reject malformed signatures rather than attempting permissive recovery. Use a timing safe comparison when implementing a protocol that requires comparing secret derived values.

Keep signing secrets in a managed secret store and scope them by endpoint and environment. Document rotation overlap and emergency revocation. Development fixtures must use separate secrets, and logs must never include the secret or a complete reusable authentication artifact.

This Python example checks an HMAC SHA256 signature over a decimal Unix timestamp, a literal period and the original request body. It limits body size, checks timestamp tolerance and compares the digest. Use the provider's verification library for its protocol, then deduplicate accepted events before processing.

python
import hashlib
import hmac
import re


def verify_reference_request(
    raw_body: bytes, timestamp: str, signature: str,
    secret: bytes, now: int, tolerance: int = 300,
) -> bool:
    if not secret or tolerance < 0 or len(raw_body) > 1_048_576:
        return False
    if not re.fullmatch(r"[0-9]{1,11}", timestamp):
        return False
    if not re.fullmatch(r"[0-9a-f]{64}", signature):
        return False
    if abs(now - int(timestamp)) > tolerance:
        return False
    signed = timestamp.encode("ascii") + b"." + raw_body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Example HMAC verification for the format described above. The request handler also needs a body size limit before reading, scope checks and durable deduplication.

Separate freshness from deduplication

A valid signature proves that covered data matches a holder of the signing material under the protocol's assumptions. It does not prove that the event has never been processed. Check timestamp tolerance where the provider includes a signed timestamp, and keep the server clock synchronized.

Then apply event deduplication at the durable processing boundary. A legitimate retry may carry a fresh signature for an existing event. Conversely, an attacker may replay a recently signed request within the allowed time window. Freshness and event identity therefore solve different parts of the problem.

Authorize the business scope

After authentication, validate the event type and payload structure. Map the configured endpoint or provider account to an allowed tenant scope. A tenant value inside a correctly signed payload still needs interpretation against the application's authorization model; the signature is not a universal grant to modify any tenant.

Minimize stored payload fields, constrain downstream fetches and avoid blindly requesting arbitrary URLs from event data. Where a provider supports fetching authoritative resources, use its documented API and verify that the returned resource belongs to the expected account.

Return failures and record verification results

Return an appropriate failure for invalid authentication and a success response only after the chosen durable acceptance step. Keep public error bodies brief. Internally record the endpoint identity, verification category and safe correlation fields so operators can distinguish configuration drift from malicious traffic.

Test altered bytes, missing headers, expired timestamps, rotated secrets, oversized requests and valid duplicate events. Include a durable storage failure after verification. A valid signature must never cause the endpoint to acknowledge work that it has already lost.

References