{"slug": "ingress-security-cryptographic-verification-enterprise-webhook-hardening-guide", "title": "Ingress Security & Cryptographic Verification: Enterprise Webhook Hardening Guide", "summary": "A developer published an enterprise webhook hardening guide describing a dual-signature verifier that authenticates incoming webhooks against both HMAC-SHA256 and ML-DSA-65 (FIPS 204), letting the algorithm be a configuration choice for post-quantum migration. The guide notes that HMAC-SHA256 remains unbroken and is not threatened by quantum computers, arguing the real case for post-quantum signatures is asymmetric schemes like Ed25519 and ECDSA that a future quantum computer would break. It also covers outbound path integrity via RPKI, DNSSEC and DANE, and flags where those controls stop helping.", "body_md": "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)**?\n\nThis 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.\n\n*Information is current as of November 2026.*\n\nThe major providers’ documented schemes are still symmetric HMAC-SHA256, and the details differ:\n\n`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.`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.`X-Shopify-Hmac-SHA256`, with no timestamp in the signed data.\nHMAC-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.\n\nHMAC does have structural limits at enterprise scale:\n\nAsymmetric 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”.\n\nIn August 2024, NIST published its first three finalized post-quantum standards:\n\nNIST’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.\n\nTwo points keep this in proportion:\n\nThe sensible goal is therefore **cryptographic agility**: build verification so that the algorithm is a configuration choice, then migrate on your own schedule.\n\nA 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.\n\n```\n            Incoming webhook (raw body + headers)\n                            │\n                            ▼\n              ┌───────────────────────────┐\n              │  Dual-signature verifier  │\n              │  1. check timestamp       │\n              │  2. build signed message  │\n              └───────┬───────────┬───────┘\n                      ▼           ▼\n             HMAC-SHA256      ML-DSA-65\n             (shared secret)  (sender public key)\n                      │           │\n                      └─────┬─────┘\n                            ▼\n                    Policy evaluation\n          (accept either / require both / require PQ)\n                            │\n                            ▼\n              Forward to internal service\n```\n\nAn illustrative request during a migration window looks like this. The header names are examples, because no industry standard for dual webhook signatures currently exists:\n\n```\nPOST /webhooks/events HTTP/1.1\nHost: api.example.com\nContent-Type: application/json\nWebhook-Timestamp: 1767225600\nWebhook-Signature-HMAC: 9f8a...4b2e\nWebhook-Signature-MLDSA65: MEUCIQ...(base64, about 4.4 kB)\n```\n\nThree design rules matter more than the algorithms:\n\nThe policy you pick determines what the dual signature actually buys you:\n\n| Policy | Passes when | Use it for | \n|---|---|---|\n| `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. | \n| `RequireBoth` | HMAC and ML-DSA are both valid | Hybrid assurance. An attacker must compromise both the shared secret and the signing key. | \n| `RequirePQ` | ML-DSA is valid | End state, once all senders have migrated. | \n\nGo 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:\n\n```\npackage webhookverify\n\nimport (\n    \"crypto/hmac\"\n    \"crypto/mldsa\"\n    \"crypto/sha256\"\n    \"encoding/base64\"\n    \"encoding/hex\"\n    \"errors\"\n    \"strconv\"\n    \"time\"\n)\n\ntype Policy int\n\nconst (\n    AcceptEither Policy = iota // migration: HMAC or ML-DSA\n    RequireBoth                // hybrid: HMAC and ML-DSA\n    RequirePQ                  // end state: ML-DSA only\n)\n\n// Domain-separation context bound into every ML-DSA signature.\nconst pqContext = \"webhook-v1\"\n\nvar (\n    ErrBadTimestamp     = errors.New(\"invalid timestamp\")\n    ErrStale            = errors.New(\"timestamp outside tolerance window\")\n    ErrInvalidSignature = errors.New(\"signature verification failed\")\n)\n\ntype Verifier struct {\n    HMACSecret []byte           // may be nil once HMAC is retired\n    PQKey      *mldsa.PublicKey // parsed once with mldsa.NewPublicKey(mldsa.MLDSA65(), raw)\n    Tolerance  time.Duration    // e.g. 5 * time.Minute\n    Policy     Policy\n}\n\n// Verify checks the raw request body against the timestamp and signature headers.\n// The signed message is \"<timestamp>.<raw body>\".\nfunc (v *Verifier) Verify(body []byte, tsHeader, hmacHex, pqBase64 string, now time.Time) error {\n    ts, err := strconv.ParseInt(tsHeader, 10, 64)\n    if err != nil {\n        return ErrBadTimestamp\n    }\n    age := now.Sub(time.Unix(ts, 0))\n    if age > v.Tolerance || age < -v.Tolerance {\n        return ErrStale\n    }\n\n    msg := append([]byte(tsHeader+\".\"), body...)\n    hmacOK := v.checkHMAC(msg, hmacHex)\n    pqOK := v.checkPQ(msg, pqBase64)\n\n    switch v.Policy {\n    case AcceptEither:\n        if hmacOK || pqOK {\n            return nil\n        }\n    case RequireBoth:\n        if hmacOK && pqOK {\n            return nil\n        }\n    case RequirePQ:\n        if pqOK {\n            return nil\n        }\n    }\n    // Log which check failed internally; do not tell the caller.\n    return ErrInvalidSignature\n}\n\nfunc (v *Verifier) checkHMAC(msg []byte, sigHex string) bool {\n    if len(v.HMACSecret) == 0 {\n        return false\n    }\n    got, err := hex.DecodeString(sigHex)\n    if err != nil {\n        return false\n    }\n    mac := hmac.New(sha256.New, v.HMACSecret)\n    mac.Write(msg)\n    return hmac.Equal(mac.Sum(nil), got) // constant-time comparison\n}\n\nfunc (v *Verifier) checkPQ(msg []byte, sigB64 string) bool {\n    if v.PQKey == nil {\n        return false\n    }\n    sig, err := base64.StdEncoding.DecodeString(sigB64)\n    if err != nil {\n        return false\n    }\n    return mldsa.Verify(v.PQKey, msg, sig, &mldsa.Options{Context: pqContext}) == nil\n}\n```\n\nAlso cap the request body size before reading it, and cache the parsed public key rather than parsing it on every request.\n\nML-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.\n\n|  | HMAC-SHA256 | ECDSA P-256 | ML-DSA-65 | \n|---|---|---|---|\n| Verification key | Shared secret | 64 bytes | 1,952 bytes | \n| Signature / tag | 32 bytes | 64 bytes | 3,309 bytes | \n| Signature as base64 | 44 characters | 88 characters | 4,412 characters | \n\nBenchmark 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.\n\nA 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.\n\nReceivers 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:\n\n```\n{\n  \"keys\": [\n    {\n      \"kty\": \"AKP\",\n      \"alg\": \"ML-DSA-65\",\n      \"kid\": \"webhooks-2026-11\",\n      \"pub\": \"<base64url, 1,952 raw bytes, about 2.6 kB encoded>\"\n    }\n  ]\n}\n```\n\nReceivers 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.\n\nIf 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.\n\nThe 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.\n\nA 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.\n\nThe 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.\n\n| Layer | What it protects | What it does *not* do | \n|---|---|---|\n| **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. | \n| **DNSSEC** | Authenticates DNS answers, so spoofed records are rejected | Does nothing to routing, and only works for zones that are signed. | \n| **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. | \n| **Mutual TLS or signed payloads** | Lets the receiver prove identity independently of the path | Requires customer cooperation. | \n\nAdoption 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.\n\nThe 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.\n\nRoutinator, 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.\n\nThe 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.\n\n``` python\nimport requests\n\nROUTINATOR = \"http://127.0.0.1:8323\"\n\ndef rpki_state(origin_asn: int, prefix: str) -> str:\n    \"\"\"Return 'valid', 'invalid' or another state (e.g. not-found) from a local Routinator.\"\"\"\n    resp = requests.get(\n        f\"{ROUTINATOR}/api/v1/validity/AS{origin_asn}/{prefix}\",\n        timeout=2,\n    )\n    resp.raise_for_status()\n    return resp.json()[\"validated_route\"][\"validity\"][\"state\"].lower()\n\n# Policy sketch:\n#   invalid   -> do not connect; alert\n#   valid     -> proceed\n#   not found -> proceed, but rely on DANE/mTLS and log it\n#   error     -> decide per your risk tolerance (fail open vs closed)\n```\n\nDANE 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).\n\n```\n_443._tcp.api.example.com. IN TLSA 3 1 1 <SHA-256 of the server's SPKI, hex>\n```\n\nThe 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.\n\n``` python\nimport hashlib\nimport http.client\nimport ssl\n\nimport dns.flags\nimport dns.resolver\nfrom cryptography import x509\nfrom cryptography.hazmat.primitives import serialization\n\nclass PathValidationError(Exception):\n    pass\n\ndef tlsa_records(host: str, port: int = 443, resolver_ip: str = \"127.0.0.1\"):\n    \"\"\"Fetch TLSA records via a *trusted local validating resolver*.\"\"\"\n    r = dns.resolver.Resolver(configure=False)\n    r.nameservers = [resolver_ip]\n    r.use_edns(0, dns.flags.DO, 1232)  # request DNSSEC data\n    answer = r.resolve(f\"_{port}._tcp.{host}\", \"TLSA\")\n    # The AD bit is only meaningful if the path to the resolver is trusted\n    # (loopback or a private link) and the resolver itself validates DNSSEC.\n    if not (answer.response.flags & dns.flags.AD):\n        raise PathValidationError(\"TLSA answer was not DNSSEC-validated (AD flag missing)\")\n    return [(a.usage, a.selector, a.mtype, a.cert) for a in answer]\n\ndef post_with_dane(host: str, path: str, body: bytes, port: int = 443):\n    records = tlsa_records(host, port)\n\n    conn = http.client.HTTPSConnection(\n        host, port, context=ssl.create_default_context(), timeout=5\n    )\n    conn.connect()\n    try:\n        der = conn.sock.getpeercert(binary_form=True)\n        spki = x509.load_der_x509_certificate(der).public_key().public_bytes(\n            serialization.Encoding.DER,\n            serialization.PublicFormat.SubjectPublicKeyInfo,\n        )\n        digest = hashlib.sha256(spki).digest()\n\n        # Support only DANE-EE / SPKI / SHA-256 (3 1 1) in this sketch.\n        if not any(u == 3 and s == 1 and m == 1 and c == digest for u, s, m, c in records):\n            raise PathValidationError(\"Server key does not match DNSSEC-signed TLSA record\")\n\n        conn.request(\"POST\", path, body=body, headers={\"Content-Type\": \"application/json\"})\n        resp = conn.getresponse()\n        return resp.status, resp.read()\n    finally:\n        conn.close()\n```\n\nOperational caveats:\n\n`AD` flag from an untrusted resolver proves nothing.\n| Concern | Typical today | Hardened design | \n|---|---|---|\n| Payload authenticity | HMAC-SHA256 with a shared secret | HMAC plus ML-DSA, moving to ML-DSA only | \n| Non-repudiation | None (symmetric key) | Public-key signature | \n| Replay protection | Timestamp window (Stripe) or none (GitHub, Shopify) | Signed timestamp, tolerance window and event-ID deduplication | \n| Algorithm change | Code change per integration | Configuration-driven policy | \n| Route hijack detection | None at the application layer | RPKI origin check (where ROAs exist) | \n| DNS integrity | Unauthenticated lookups | DNSSEC-validated resolution | \n| Endpoint identity | Public CA trust (hardened by MPIC) | DANE pin, plus optional mutual TLS | \n\n`AcceptEither` while collecting telemetry on which senders support ML-DSA.`RequireBoth`, then to `RequirePQ` once all senders have migrated, and retire shared secrets.\nHMAC-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.\n\n`crypto/mldsa` package: *Originally published at [https://instawebhook.com/blog/ingress-security-cryptographic-verification-enterprise-webhook-hardening-guide](https://instawebhook.com/blog/ingress-security-cryptographic-verification-enterprise-webhook-hardening-guide)*", "url": "https://wpnews.pro/news/ingress-security-cryptographic-verification-enterprise-webhook-hardening-guide", "canonical_source": "https://dev.to/instawebhook/ingress-security-cryptographic-verification-enterprise-webhook-hardening-guide-2ecf", "published_at": "2026-11-06 02:20:06+00:00", "updated_at": "2026-11-06 02:22:36.996851+00:00", "lang": "en", "topics": ["ai-safety"], "entities": ["NIST", "Stripe", "GitHub", "Shopify", "ML-DSA", "FIPS 204", "Ed25519", "ECDSA"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/ingress-security-cryptographic-verification-enterprise-webhook-hardening-guide", "markdown": "https://wpnews.pro/news/ingress-security-cryptographic-verification-enterprise-webhook-hardening-guide.md", "text": "https://wpnews.pro/news/ingress-security-cryptographic-verification-enterprise-webhook-hardening-guide.txt", "jsonld": "https://wpnews.pro/news/ingress-security-cryptographic-verification-enterprise-webhook-hardening-guide.jsonld"}}