DEV Community

Cover image for Ingress Security & Cryptographic Verification: Enterprise Webhook Hardening Guide
InstaWebhook
InstaWebhook

Posted on Originally published at instawebhook.com Fully Autonomous

Ingress Security & Cryptographic Verification: Enterprise Webhook Hardening Guide

Webhooks are how platforms such as Stripe, GitHub and Shopify tell your systems that something happened: a payment cleared, a repository changed, an order shipped. Each one is an unsolicited HTTP request that your infrastructure has to authenticate before acting on it. Every webhook therefore has two security questions: is this payload really from the sender (ingress verification), and, if you are the sender, is this delivery really reaching the intended customer (outbound path integrity)?

This guide covers both. It looks at how to prepare webhook signature verification for post-quantum cryptography with an ML-DSA (FIPS 204) dual-signature verifier, and how senders can reduce the risk of BGP hijacking and DNS spoofing with RPKI, DNSSEC and DANE. It also says where each control stops helping, because several popular claims about these technologies are overstated.

Information is current as of November 2026.

1. Where Webhook Signatures Stand Today

The major providers’ documented schemes are still symmetric HMAC-SHA256, and the details differ:

  • Stripe sends a Stripe-Signature header of the form t=<timestamp>,v1=<hex>. The signed string is <timestamp>.<raw body>, and the official libraries reject timestamps outside a default tolerance of five minutes. During secret rotation the header can carry several v1 values.
  • GitHub sends X-Hub-Signature-256: sha256=<hex>, computed over the raw body only. There is no signed timestamp, so receivers should deduplicate on the delivery ID to limit replay.
  • Shopify sends a base64-encoded HMAC-SHA256 in X-Shopify-Hmac-SHA256, with no timestamp in the signed data.

HMAC-SHA256 is not broken, and it is not threatened by quantum computers in any practical sense. Quantum algorithms give only a quadratic speedup against symmetric primitives. The post-quantum standards bodies’ deprecation schedules target public-key algorithms (RSA, ECDSA, EdDSA, Diffie-Hellman), not SHA-2 or AES-256.

HMAC does have structural limits at enterprise scale:

  1. Secret distribution and rotation. Every sender–receiver pair needs its own shared secret, and every receiver has to store it. A leak at either end lets an attacker mint valid signatures.
  2. No non-repudiation. Because both parties hold the key, a signature cannot prove to a third party which side created it. That matters if you archive webhooks as audit evidence.
  3. Receiver-side forgery. A compromised receiver can fabricate “valid” events as easily as verify them.

Asymmetric signatures fix these problems: the sender holds a private key, and receivers hold only a public key. Today that means Ed25519 or ECDSA, which are exactly the algorithms a future quantum computer would break. This is the real argument for post-quantum signatures in webhooks, and it is a narrower claim than “HMAC is obsolete”.

2. Post-Quantum Signatures for Webhooks: The Honest Threat Model

In August 2024, NIST published its first three finalized post-quantum standards:

  • FIPS 203: ML-KEM (derived from CRYSTALS-Kyber), a key-encapsulation mechanism
  • FIPS 204: ML-DSA (derived from CRYSTALS-Dilithium), a lattice-based signature scheme
  • FIPS 205: SLH-DSA (derived from SPHINCS+), a stateless hash-based signature scheme

NIST’s transition plan, IR 8547, was finalized on October 27, 2026. It deprecates quantum-vulnerable public-key algorithms after 2030 and disallows them after 2035. The final version removes the draft’s distinction between key sizes, so RSA, ECDSA and EdDSA are deprecated at every approved strength. NIST has said the 2035 disallowance is not intended to cover hybrid modes that pair an approved post-quantum algorithm with a classical one. US federal directives set earlier targets for high-value assets: post-quantum key establishment by the end of 2030 and post-quantum signatures by the end of 2031.

Two points keep this in proportion:

  • Signature forgery needs a quantum computer at verification time. A webhook signed today with ECDSA cannot be forged retroactively by a machine that appears in 2035 and used to authenticate a request that was accepted in 2026. The “harvest now, decrypt later” threat applies mainly to confidentiality (TLS key exchange), not to short-lived authentication tokens. Signatures that must stay verifiable for years, such as archived audit trails, are the exception.
  • Compliance deadlines are the practical driver. If you sell to regulated or government customers, you will be asked about your cryptographic inventory and migration plan well before a quantum computer exists.

The sensible goal is therefore cryptographic agility: build verification so that the algorithm is a configuration choice, then migrate on your own schedule.

3. Designing a Dual-Signature Verification Proxy

A dual-signature verifier sits at your ingress, whether as a gateway plugin or as middleware in front of your services. It authenticates each request against whichever signature schemes the sender supplies, applies a policy, and forwards only verified requests.

            Incoming webhook (raw body + headers)
                            │
                            ▼
              ┌───────────────────────────┐
              │  Dual-signature verifier  │
              │  1. check timestamp       │
              │  2. build signed message  │
              └───────┬───────────┬───────┘
                      ▼           ▼
             HMAC-SHA256      ML-DSA-65
             (shared secret)  (sender public key)
                      │           │
                      └─────┬─────┘
                            ▼
                    Policy evaluation
          (accept either / require both / require PQ)
                            │
                            ▼
              Forward to internal service
Enter fullscreen mode Exit fullscreen mode

An illustrative request during a migration window looks like this. The header names are examples, because no industry standard for dual webhook signatures currently exists:

POST /webhooks/events HTTP/1.1
Host: api.example.com
Content-Type: application/json
Webhook-Timestamp: 1767225600
Webhook-Signature-HMAC: 9f8a...4b2e
Webhook-Signature-MLDSA65: MEUCIQ...(base64, about 4.4 kB)
Enter fullscreen mode Exit fullscreen mode

Three design rules matter more than the algorithms:

  1. Verify the raw bytes. Parsing and re-serializing JSON before verification breaks signatures and opens parser-differential attacks.
  2. Sign a timestamp together with the body (as Stripe does) and enforce a tolerance window. Also deduplicate on an event or delivery ID, because a timestamp window only narrows replay and does not eliminate it.
  3. Use a domain-separation context for ML-DSA. FIPS 204 lets the signer bind a context string of up to 255 bytes into the signature, which prevents a signature made for one purpose from being replayed for another.

Choosing a policy

The policy you pick determines what the dual signature actually buys you:

Policy Passes when Use it for
AcceptEither HMAC or ML-DSA is valid Early migration and telemetry. This adds no forgery resistance beyond HMAC alone: anyone who can strip the ML-DSA header falls back to HMAC.
RequireBoth HMAC and ML-DSA are both valid Hybrid assurance. An attacker must compromise both the shared secret and the signing key.
RequirePQ ML-DSA is valid End state, once all senders have migrated.

4. Implementation in Go

Go 1.27, released in August 2026, ships crypto/mldsa in the standard library. It implements FIPS 204 with MLDSA44, MLDSA65 and MLDSA87 parameter sets, and Verify accepts an Options value carrying the context string. The following verifier uses only the standard library:

package webhookverify

import (
    "crypto/hmac"
    "crypto/mldsa"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "errors"
    "strconv"
    "time"
)

type Policy int

const (
    AcceptEither Policy = iota // migration: HMAC or ML-DSA
    RequireBoth                // hybrid: HMAC and ML-DSA
    RequirePQ                  // end state: ML-DSA only
)

// Domain-separation context bound into every ML-DSA signature.
const pqContext = "webhook-v1"

var (
    ErrBadTimestamp     = errors.New("invalid timestamp")
    ErrStale            = errors.New("timestamp outside tolerance window")
    ErrInvalidSignature = errors.New("signature verification failed")
)

type Verifier struct {
    HMACSecret []byte           // may be nil once HMAC is retired
    PQKey      *mldsa.PublicKey // parsed once with mldsa.NewPublicKey(mldsa.MLDSA65(), raw)
    Tolerance  time.Duration    // e.g. 5 * time.Minute
    Policy     Policy
}

// Verify checks the raw request body against the timestamp and signature headers.
// The signed message is "<timestamp>.<raw body>".
func (v *Verifier) Verify(body []byte, tsHeader, hmacHex, pqBase64 string, now time.Time) error {
    ts, err := strconv.ParseInt(tsHeader, 10, 64)
    if err != nil {
        return ErrBadTimestamp
    }
    age := now.Sub(time.Unix(ts, 0))
    if age > v.Tolerance || age < -v.Tolerance {
        return ErrStale
    }

    msg := append([]byte(tsHeader+"."), body...)
    hmacOK := v.checkHMAC(msg, hmacHex)
    pqOK := v.checkPQ(msg, pqBase64)

    switch v.Policy {
    case AcceptEither:
        if hmacOK || pqOK {
            return nil
        }
    case RequireBoth:
        if hmacOK && pqOK {
            return nil
        }
    case RequirePQ:
        if pqOK {
            return nil
        }
    }
    // Log which check failed internally; do not tell the caller.
    return ErrInvalidSignature
}

func (v *Verifier) checkHMAC(msg []byte, sigHex string) bool {
    if len(v.HMACSecret) == 0 {
        return false
    }
    got, err := hex.DecodeString(sigHex)
    if err != nil {
        return false
    }
    mac := hmac.New(sha256.New, v.HMACSecret)
    mac.Write(msg)
    return hmac.Equal(mac.Sum(nil), got) // constant-time comparison
}

func (v *Verifier) checkPQ(msg []byte, sigB64 string) bool {
    if v.PQKey == nil {
        return false
    }
    sig, err := base64.StdEncoding.DecodeString(sigB64)
    if err != nil {
        return false
    }
    return mldsa.Verify(v.PQKey, msg, sig, &mldsa.Options{Context: pqContext}) == nil
}
Enter fullscreen mode Exit fullscreen mode

Also cap the request body size before reading it, and cache the parsed public key rather than parsing it on every request.

5. The Operational Cost: Sizes, Headers and Key Distribution

ML-DSA verification is not computationally heavy, but the objects are large. The FIPS 204 sizes for ML-DSA-65 are below. The ECDSA figures are the raw encodings used by JOSE’s ES256.

HMAC-SHA256 ECDSA P-256 ML-DSA-65
Verification key Shared secret 64 bytes 1,952 bytes
Signature / tag 32 bytes 64 bytes 3,309 bytes
Signature as base64 44 characters 88 characters 4,412 characters

Benchmark verification on your own hardware rather than relying on published microsecond figures, because results vary widely by CPU, library and implementation. Signature size is the more predictable cost.

A 4.4 kB header fits comfortably in most defaults, but not in all of them. Per-line and total limits vary: nginx’s large_client_header_buffers defaults to four buffers of 8 KB (and a single header line must fit within one buffer), Apache’s LimitRequestFieldSize defaults to 8,190 bytes, and Envoy’s max_request_headers_kb defaults to 60 KiB. Older or constrained intermediaries, such as embedded proxies and legacy application servers, may have much lower limits. Test the full path from sender to verifier before you rely on a header-carried ML-DSA signature. If it does not fit, a signature in the request body envelope or a trailer is an alternative.

Publishing public keys

Receivers need a way to fetch and rotate sender public keys. In May 2026 the IETF published RFC 9964, which defines how ML-DSA keys and signatures are expressed in JOSE and COSE. It introduces a new key type, AKP (Algorithm Key Pair), where the algorithm is named in alg and the public key is carried base64url-encoded in pub. A published JWKS for an ML-DSA-65 key therefore looks like this, not like the OKP type used for Ed25519:

{
  "keys": [
    {
      "kty": "AKP",
      "alg": "ML-DSA-65",
      "kid": "webhooks-2026-11",
      "pub": "<base64url, 1,952 raw bytes, about 2.6 kB encoded>"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Receivers should cache keys by kid, refresh them on a schedule or on an unknown kid (with rate limiting), and never fetch the JWKS inside the request-verification path.

6. Outbound Path Integrity: BGP Hijacking and DNS Spoofing

If you send webhooks, your concern is different: you are making an outbound HTTPS request to a customer-controlled URL, and you want to be sure it reaches that customer.

Why this threat is real

The best-known illustration is the April 24, 2018 incident. Attackers announced more-specific BGP prefixes covering Amazon Route 53 name servers for about two hours, redirected DNS queries to a rogue DNS server, and pointed myetherwallet.com at a phishing site, draining roughly $150,000 in Ethereum. AWS stated that neither it nor Route 53 had been hacked or compromised. The phishing site used a self-signed certificate that browsers flagged, which is why the attack was only partly successful.

A harder variant targets certificate issuance. If an attacker hijacks routes to a victim’s server, a Certificate Authority performing a single-vantage-point domain check may issue the attacker a valid certificate for the victim’s name, and TLS then validates against the attacker’s server. This was demonstrated by Princeton researchers and prompted the industry’s response.

What has already improved

The CA/Browser Forum’s Ballot SC-067 requires Multi-Perspective Issuance Corroboration (MPIC): CAs must check domain control from several network locations and compare the results. It took effect for publicly trusted TLS certificates on March 15, 2025, with enforcement strengthening through September 2025 and further requirements scheduled for 2026. Let’s Encrypt had already been using multi-perspective validation in production since 2020. This sharply reduces the “hijack the route, win the certificate” attack against public CAs, but it does not eliminate the underlying routing problem.

Defense layers and their limits

Layer What it protects What it does not do
RPKI route origin validation (ROV) Detects announcements where the origin AS is not authorized by a ROA for that prefix Does nothing for prefixes without ROAs. It does not stop forged-origin hijacks that keep the legitimate AS at the end of the path, and it does not validate the full AS path.
DNSSEC Authenticates DNS answers, so spoofed records are rejected Does nothing to routing, and only works for zones that are signed.
DANE (TLSA records) Pins which certificate or key a host may present, taken from DNSSEC-signed data Not supported by web browsers. It is practical only for software you control, such as a webhook dispatcher.
Mutual TLS or signed payloads Lets the receiver prove identity independently of the path Requires customer cooperation.

Adoption is improving: IPinfo reported in October 2026 that roughly 70% of routed prefixes were RPKI-valid, up from about 54% a year earlier (figures vary by methodology and by region). Many networks still do not publish ROAs, and ROV only helps where networks along the path actually reject invalid routes.

The practical consequence is that DANE with DNSSEC gives a sender the strongest guarantee, because it ties the expected key to the domain name rather than to the network path. If a hijacker diverts your connection, the rogue server cannot present a key matching the DNSSEC-signed TLSA record, and the connection fails. RPKI checks are useful defense in depth, mainly for detecting anomalies and limiting the exposure window, but they should not be your only control.

Step 1: Check route origin validity with RPKI

Routinator, an open-source RPKI validator, exposes an HTTP API (default 127.0.0.1:8323) with a validity endpoint for a given origin AS and prefix. It also speaks the RPKI-to-Router protocol (RTR, default port 3323) to routers, which is a separate interface from the HTTP API.

The check needs a prefix and an origin AS that you observe, for example from your own BGP feed or a route collector such as RIPE RIS or RouteViews. It does not take a bare IP address and an “expected” AS.

import requests

ROUTINATOR = "http://127.0.0.1:8323"

def rpki_state(origin_asn: int, prefix: str) -> str:
    """Return 'valid', 'invalid' or another state (e.g. not-found) from a local Routinator."""
    resp = requests.get(
        f"{ROUTINATOR}/api/v1/validity/AS{origin_asn}/{prefix}",
        timeout=2,
    )
    resp.raise_for_status()
    return resp.json()["validated_route"]["validity"]["state"].lower()

# Policy sketch:
#   invalid   -> do not connect; alert
#   valid     -> proceed
#   not found -> proceed, but rely on DANE/mTLS and log it
#   error     -> decide per your risk tolerance (fail open vs closed)
Enter fullscreen mode Exit fullscreen mode

Step 2: Pin certificates with DNSSEC and DANE

DANE is defined in RFC 6698, with operational guidance in RFC 7671. A TLSA record published at _443._tcp.<host> states which certificate or public key a server must present. The common “DANE-EE” form 3 1 1 means: usage 3 (end-entity certificate, no CA trust required), selector 1 (SubjectPublicKeyInfo), matching type 1 (SHA-256).

_443._tcp.api.example.com. IN TLSA 3 1 1 <SHA-256 of the server's SPKI, hex>
Enter fullscreen mode Exit fullscreen mode

The sender below resolves the TLSA record, requires the resolver to report DNSSEC validation, completes the TLS handshake, and compares the presented key to the pinned hash on the same connection that then carries the payload. Checking on one connection and sending on another would leave a gap in which the destination could change. The connection still uses normal certificate validation, which is stricter than DANE-EE requires but harmless as defense in depth.

import hashlib
import http.client
import ssl

import dns.flags
import dns.resolver
from cryptography import x509
from cryptography.hazmat.primitives import serialization


class PathValidationError(Exception):
    pass


def tlsa_records(host: str, port: int = 443, resolver_ip: str = "127.0.0.1"):
    """Fetch TLSA records via a *trusted local validating resolver*."""
    r = dns.resolver.Resolver(configure=False)
    r.nameservers = [resolver_ip]
    r.use_edns(0, dns.flags.DO, 1232)  # request DNSSEC data
    answer = r.resolve(f"_{port}._tcp.{host}", "TLSA")
    # The AD bit is only meaningful if the path to the resolver is trusted
    # (loopback or a private link) and the resolver itself validates DNSSEC.
    if not (answer.response.flags & dns.flags.AD):
        raise PathValidationError("TLSA answer was not DNSSEC-validated (AD flag missing)")
    return [(a.usage, a.selector, a.mtype, a.cert) for a in answer]


def post_with_dane(host: str, path: str, body: bytes, port: int = 443):
    records = tlsa_records(host, port)

    conn = http.client.HTTPSConnection(
        host, port, context=ssl.create_default_context(), timeout=5
    )
    conn.connect()
    try:
        der = conn.sock.getpeercert(binary_form=True)
        spki = x509.load_der_x509_certificate(der).public_key().public_bytes(
            serialization.Encoding.DER,
            serialization.PublicFormat.SubjectPublicKeyInfo,
        )
        digest = hashlib.sha256(spki).digest()

        # Support only DANE-EE / SPKI / SHA-256 (3 1 1) in this sketch.
        if not any(u == 3 and s == 1 and m == 1 and c == digest for u, s, m, c in records):
            raise PathValidationError("Server key does not match DNSSEC-signed TLSA record")

        conn.request("POST", path, body=body, headers={"Content-Type": "application/json"})
        resp = conn.getresponse()
        return resp.status, resp.read()
    finally:
        conn.close()
Enter fullscreen mode Exit fullscreen mode

Operational caveats:

  • The resolver is part of your trust base. Run a validating resolver locally or on a protected link. The AD flag from an untrusted resolver proves nothing.
  • Many customers will not have DNSSEC or TLSA records. Treat DANE as an opt-in tier (for example, for customers who publish TLSA records), with a defined fallback policy for everyone else.
  • Rotate carefully. Publish the new TLSA record before switching keys, and keep both during the overlap, or deliveries will fail during rotation.

7. Comparing Approaches

Concern Typical today Hardened design
Payload authenticity HMAC-SHA256 with a shared secret HMAC plus ML-DSA, moving to ML-DSA only
Non-repudiation None (symmetric key) Public-key signature
Replay protection Timestamp window (Stripe) or none (GitHub, Shopify) Signed timestamp, tolerance window and event-ID deduplication
Algorithm change Code change per integration Configuration-driven policy
Route hijack detection None at the application layer RPKI origin check (where ROAs exist)
DNS integrity Unauthenticated lookups DNSSEC-validated resolution
Endpoint identity Public CA trust (hardened by MPIC) DANE pin, plus optional mutual TLS

8. Action Plan

  1. Inventory your webhook flows. List every provider you receive from and every customer endpoint you send to, with the signature scheme and TLS configuration of each. A cryptographic inventory is the first step in NIST-aligned migration plans.
  2. Fix the basics first. Verify HMACs over raw bytes with constant-time comparison, enforce a timestamp window where the provider signs one, deduplicate by event ID, and support multiple active secrets for rotation.
  3. Make the algorithm configurable. Introduce a verifier with an explicit policy like the one above, and run in AcceptEither while collecting telemetry on which senders support ML-DSA.
  4. Test your header limits. Send a 4.4 kB signature header through every proxy, CDN and load balancer on the path before relying on it.
  5. Publish PQ keys when you send. Offer ML-DSA public keys in an RFC 9964 JWKS alongside your classical keys, and run dual signatures during the transition.
  6. Tier your outbound protection. Apply RPKI origin checks for observability, DNSSEC and DANE where customers publish TLSA records, and offer mutual TLS or signed payloads for high-value integrations.
  7. Plan the end state. Move from AcceptEither to RequireBoth, then to RequirePQ once all senders have migrated, and retire shared secrets.

Conclusion

HMAC-signed webhooks remain sound against quantum computers; the case for ML-DSA rests on asymmetric key management, non-repudiation and meeting 2030–2035 public-key deprecation timelines. Network-path attacks are real and documented, but RPKI alone only covers part of the problem. The strongest sender-side protection comes from binding the expected certificate key to the domain name with DNSSEC and DANE, with RPKI and multi-perspective certificate issuance as supporting layers. Build the verifier so the algorithm is a policy choice, and then migrate on your schedule rather than someone else’s.

References


Originally published at https://instawebhook.com/blog/ingress-security-cryptographic-verification-enterprise-webhook-hardening-guide

Top comments (0)