Webhook Signatures Explained: HMAC vs RSA vs Ed25519
Webhook Signatures Explained: HMAC vs RSA vs Ed25519 The silent vulnerability in your API infrastructure Webhooks are the backbone of modern event-driven architectures.

Webhook Signatures Explained: HMAC vs RSA vs Ed25519
The silent vulnerability in your API infrastructure
Webhooks are the backbone of modern event-driven architectures. Every time a customer completes a checkout on Stripe, pushes code on GitHub, or triggers a workflow in an automation tool, an HTTP POST request carries that event payload straight to your application's public endpoint.
That's also the problem. A webhook endpoint is just a URL sitting on the open internet. Unless you tell it otherwise, your server has no way to distinguish a genuine event from Stripe and a POST request crafted by anyone who found (or guessed) the URL.
[ Unverified Request ] ──> https://api.yourcompany.com/webhooks/stripe ──> [ Action Triggered ]
Without a way to verify the sender, an endpoint like this is exposed to three concrete risks:
- Spoofing — a forged "payment succeeded" or "subscription renewed" event that grants access or ships a product for free.
- Tampering — a payload altered in transit before it reaches you.
- Replay — a previously valid, correctly signed request captured and resent to trigger the same action twice.
This is exactly what happened in a real, disclosed vulnerability from January 2026: CVE-2026-21894 in the workflow-automation tool n8n. Its Stripe Trigger node generated and stored a webhook signing secret, but the incoming request handler never actually checked incoming requests against it. Anyone who knew the webhook URL could POST a fabricated event and the workflow would run as if Stripe had sent it — no signature required. The fix wasn't a new cryptographic scheme; it was simply using the verification that was already sitting there unused. That's the pattern behind most webhook security failures: the crypto is fine, the wiring is broken.
To prevent this class of bug, providers sign every outgoing payload with a key, so your server can cryptographically confirm the request is authentic and unmodified. This article walks through how that signing works, compares the three schemes you'll actually encounter — HMAC, RSA, and Ed25519 — surveys how real providers implement them, and covers the implementation mistakes that break verification even when the underlying algorithm is sound.
How webhook signatures work
A webhook signature is a cryptographic digest computed over the payload (and usually a timestamp and a message ID), attached to the request as an HTTP header. Your receiver repeats the same computation and checks that the two values match.
A well-built webhook delivery typically carries three pieces of information:
- A unique message identifier so you can deduplicate retried deliveries and enforce idempotency.
- A timestamp, usually folded into the signed content, so you can reject requests that are older than a short tolerance window (commonly five minutes).
- The signature itself, in a header such as
Stripe-Signature,X-Hub-Signature-256, or the genericwebhook-signature.
POST /webhooks/receive HTTP/1.1
Host: api.yourcompany.com
Content-Type: application/json
webhook-id: msg_2eaf7c9b10
webhook-timestamp: 1753193011
webhook-signature: v1,g0hM9SsE9BqjT8pReExtn4hQoK7oX0dY9lNv2xY6r1o=
{"event": "payment_intent.succeeded", "amount": 4900}
There are two broad families of cryptography behind this: symmetric (a shared secret both sides know) and asymmetric (a private key that signs, and a public key that verifies).
HMAC, RSA, and Ed25519, compared
Webhook Cryptography Schemes
│
┌───────────────┴────────────────┐
▼ ▼
Symmetric (shared secret) Asymmetric (key pair)
│ │
▼ ┌──────────┼───────────┐
HMAC-SHA256 ▼ ▼ ▼
(Stripe, GitHub, Svix) RSA ECDSA Ed25519
(legacy, (SendGrid) (Discord,
JWKS) Telnyx v2)
1. HMAC — the default almost everyone reaches for
Both the provider and the receiver hold the same secret string (Stripe secrets look like whsec_...). The sender computes HMAC-SHA256(secret, signed_content) and sends the digest in a header; the receiver repeats the calculation over the raw request body and compares.
How real providers do it:
- Stripe sends a
Stripe-Signatureheader shaped liket=1700000000,v1=5257a8.... It requires you to concatenate the timestamp, a., and the raw body before hashing — Stripe's own troubleshooting docs point to this as the single most common source of verification failures, because most web frameworks parse the JSON body before your handler ever sees it, and the signature only matches the exact original bytes. - GitHub sends
X-Hub-Signature-256, an HMAC-SHA256 hex digest of the raw request body, prefixed withsha256=. A legacyX-Hub-Signature(SHA-1) is still sent for backward compatibility, but GitHub's own docs recommend ignoring it in new code. - Svix, the widely-used webhooks-as-a-service platform (and the driving force behind the open Standard Webhooks spec — more on that below), uses HMAC-SHA256 by default and signs the message ID, timestamp, and body together.
One nuance worth flagging: GitHub's signature scheme doesn't include a timestamp, so it has no built-in replay window the way Stripe or Standard Webhooks do. GitHub's own guidance instead leans on the unique X-GitHub-Delivery ID for deduplication and on HTTPS/secret confidentiality for authenticity — but a captured, valid GitHub payload can technically be replayed unless you add your own freshness or duplicate-ID check. Don't assume every "signed" webhook automatically has replay protection; check whether a timestamp is actually part of what got signed.
Advantages:
- Extremely fast. Svix's own benchmarks put symmetric HMAC at roughly 50x faster to sign and 160x faster to verify than an equivalent asymmetric scheme.
- Trivial to implement —
crypto.createHmacin Node,hmacin Python, and equivalents exist in every mainstream language.
Disadvantages:
- The secret exists on both ends. If your environment variables or secret store leak, an attacker can forge signatures indistinguishable from the real thing.
- No non-repudiation: because both parties can produce a valid signature, a receiver technically can't prove a specific payload came from the sender rather than being self-forged.
- Providers sending to many customers have to safely generate, store, and rotate one secret per receiving endpoint.
2. RSA — asymmetric, but heavy
The provider holds a private key and publishes the corresponding public key, often via a .well-known/jwks.json endpoint. Consumers verify signatures without ever holding secret material.
Advantages:
- No shared secret to leak. Compromising a receiver's server doesn't give an attacker anything usable to forge webhooks aimed at other receivers.
- One public key infrastructure serves every consumer.
Disadvantages:
- RSA-2048 signatures run ~256 bytes (longer in base64), adding real header bloat next to HMAC's 32-byte digest.
- Modular exponentiation is CPU-heavy; at high throughput this is a measurable cost, which is a real reason it's uncommon as the default choice for high-volume webhook platforms.
- Managing key rotation, certificate chains, and revocation is genuinely more operational overhead than a shared secret.
In practice, plain RSA-signed webhooks are rare outside legacy enterprise integrations — most providers who want asymmetric signing today reach for elliptic-curve schemes instead, which give the same non-repudiation property with smaller keys and faster verification.
3. Ed25519 — the modern asymmetric option
Ed25519 is an EdDSA signature scheme over Curve25519, purpose-built to avoid RSA's size and speed problems while keeping asymmetric guarantees: 32-byte public keys, 64-byte signatures, and verification that's meaningfully faster than RSA (though still slower than HMAC).
It's a real, adopted standard for webhooks — not a theoretical option:
- Discord signs interaction-endpoint payloads with Ed25519, verified via the
X-Signature-Ed25519andX-Signature-Timestampheaders against the public key from your application's settings. - Telnyx's Webhook API v2 signs every event with Ed25519 (
telnyx-signature-ed25519+telnyx-timestampheaders), verified against your account's public key; the older v1 API was unsigned entirely. - Svix supports Ed25519 as an explicit alternative to its HMAC default, for senders that specifically need non-repudiation or want to avoid distributing per-endpoint secrets.
Ed25519's design also makes constant-time execution the default at the primitive level, which removes a class of timing side-channel bugs that historically had to be hand-coded around when implementing RSA.
Not every "asymmetric" webhook in the wild is Ed25519, though — SendGrid's Signed Event Webhook, for instance, uses ECDSA (elliptic-curve DSA) over a provider-generated key pair, with a X-Twilio-Email-Event-Webhook-Timestamp header for replay protection. It's a good reminder that "asymmetric" is a category, not a single algorithm — always check the provider's actual docs rather than assuming.
Detailed comparison
| Metric | HMAC-SHA256 | RSA-2048 | Ed25519 (EdDSA) |
|---|---|---|---|
| Cryptography type | Symmetric | Asymmetric | Asymmetric |
| Relative verify speed | Fastest (baseline) | ~100–300x slower than HMAC | Meaningfully faster than RSA, slower than HMAC |
| Key size | 32–64 byte secret | 2048–4096 bit | 32-byte public key |
| Signature size | ~32 bytes (64 hex chars) | 256–512 bytes | 64 bytes |
| Key distribution | Per-endpoint shared secret | Global JWKS / public URL | Global public key |
| If a receiver is breached | Attacker can forge signatures for that receiver | Attacker gains nothing usable against other receivers | Attacker gains nothing usable against other receivers |
| Non-repudiation | No — both sides can produce valid signatures | Yes | Yes |
| Developer ergonomics | High — native stdlib support everywhere | Low — needs PKI/X.509 tooling | Medium — needs a modern EdDSA library |
The 50x/160x sign/verify speed gap between HMAC and asymmetric schemes is Svix's own published figure for their implementation, and it's the main reason HMAC-SHA256 remains the default for the highest-volume senders (Stripe, GitHub, Svix itself) even though asymmetric schemes solve a real problem HMAC doesn't.
The Standard Webhooks specification
A meaningful recent development in this space is Standard Webhooks, an open specification (maintained by Svix and adopted by a growing list of API platforms) that standardizes the webhook-id, webhook-timestamp, and webhook-signature headers, the exact bytes that get signed, and tolerance/rotation behavior — for both symmetric and asymmetric signing.
The point isn't a new algorithm; it's interoperability. Today, every provider invents its own header names and its own "what exactly gets hashed" convention, which is why webhook verification code can't be reused across providers even though the underlying crypto is nearly identical. A shared spec means a single verification library — or, as the spec's own documentation notes, verification implemented once at the API gateway level — can cover any compliant sender, instead of every consumer hand-rolling per-provider logic. It's also explicit that a stray whitespace difference from re-serializing JSON is enough to break a signature that was otherwise computed correctly, which is the single most common bug in the wild (see below).
Six implementation mistakes that break verification
Even with a sound algorithm, the receiving side is where verification most often fails in practice.
1. Verifying re-serialized JSON instead of the raw body
Frameworks like Express, Django, or Spring often parse the request body into an object before your handler runs. If you then call JSON.stringify() on that object to verify, you're hashing different bytes than the sender signed — key order, whitespace, and number formatting can all shift during parsing and re-serialization.
// ❌ WRONG — parsing then re-serializing changes the byte sequence
app.post('/webhook', express.json(), (req, res) => {
const rawBody = JSON.stringify(req.body);
const computedSig = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
// mismatch, even though the payload is "the same" logically
});
// ✅ CORRECT — verify against the untouched raw buffer, then parse
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const rawBuffer = req.body;
const computedSig = crypto.createHmac('sha256', secret).update(rawBuffer).digest('hex');
if (crypto.timingSafeEqual(Buffer.from(computedSig), Buffer.from(expectedHeaderSig))) {
const payload = JSON.parse(rawBuffer.toString('utf-8'));
// process safely
}
});
2. Comparing signatures with == or ===
Standard string comparison short-circuits on the first mismatched character, which means comparison time leaks information about how many leading characters were correct — a timing side channel an attacker can exploit character-by-character over enough requests.
// ❌ vulnerable to timing attacks
if (receivedSignature === expectedSignature) { /* ... */ }
// ✅ constant-time comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(receivedSignature, 'utf-8'),
Buffer.from(expectedSignature, 'utf-8')
);
3. Skipping timestamp validation
A valid signature only proves the payload came from the right sender — not when. Without a freshness check, a captured request (from logs, a proxy, a compromised intermediary) can be replayed indefinitely with a perfectly valid signature attached.
const DEFAULT_TOLERANCE_SECONDS = 300; // 5 minutes, matching Stripe's and Standard Webhooks' default
function isTimestampValid(headerTimestamp) {
const now = Math.floor(Date.now() / 1000);
return Math.abs(now - headerTimestamp) <= DEFAULT_TOLERANCE_SECONDS;
}
Note that this only works if the provider actually signs a timestamp (Stripe and Standard-Webhooks-compliant senders do; plain GitHub webhooks, as noted above, don't).
4. A brittle single-secret rotation strategy
If your receiver only ever checks one secret at a time, rotating it means either downtime or dropped events during the cutover. Stripe and Standard Webhooks handle this by sending multiple space- or comma-delimited signature values during a transition window, so both the old and new secret validate.
function verifyDuringRotation(rawPayload, signaturesHeader, secrets) {
return secrets.some(secret => {
const computed = computeHmac(rawPayload, secret);
return signaturesHeader.split(' ').some(sig => timingSafeEqual(computed, sig));
});
}
5. Returning detailed errors to the sender
A response body like "Invalid HMAC at byte 14" hands an attacker a debugging oracle. Return a generic 401/400, and log the specifics server-side only.
6. Reusing one asymmetric key pair across every customer
This one is specific to asymmetric schemes and is easy to miss: if a multi-tenant platform signs every outgoing webhook — for every customer — with the same private key, then any customer holding the shared public key can also forge signatures that pass verification for other customers' endpoints. Svix's own engineering write-up on webhook signature failure modes flags this as a subtle but real bug: asymmetric signing only delivers its security benefit if each tenant (or at minimum each sender identity) has its own key pair, not one shared globally. HMAC has an equivalent version of this mistake — reusing one secret across all customers instead of provisioning per-endpoint secrets — so the fix in both cases is the same: scope the signing key to the specific relationship, not the whole platform.
Production verification code
Node.js / TypeScript
import crypto from 'crypto';
interface VerifyOptions {
rawBody: Buffer;
signatureHeader: string; // "t=1753193011,v1=9f8a..."
secret: string;
toleranceInSeconds?: number;
}
export function verifyWebhookSignature({
rawBody,
signatureHeader,
secret,
toleranceInSeconds = 300
}: VerifyOptions): boolean {
const parts = signatureHeader.split(',').reduce<Record<string, string>>((acc, item) => {
const [key, value] = item.split('=');
if (key && value) acc[key.trim()] = value.trim();
return acc;
}, {});
const timestamp = parts['t'];
const signature = parts['v1'];
if (!timestamp || !signature) throw new Error('Invalid signature header structure');
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > toleranceInSeconds) {
throw new Error('Timestamp tolerance exceeded — possible replay');
}
const signedPayload = `${timestamp}.${rawBody.toString('utf-8')}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf-8')
.digest('hex');
const expectedBuf = Buffer.from(expectedSignature, 'utf-8');
const receivedBuf = Buffer.from(signature, 'utf-8');
if (expectedBuf.length !== receivedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}
Python (FastAPI)
import hmac
import hashlib
import time
from fastapi import Request, HTTPException, status
WEBHOOK_SECRET = "whsec_your_shared_secret_here"
TOLERANCE_SECONDS = 300
async def verify_webhook(request: Request):
raw_body = await request.body()
signature_header = request.headers.get("stripe-signature")
if not signature_header:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing signature header")
header_dict = dict(item.split("=") for item in signature_header.split(",") if "=" in item)
timestamp = header_dict.get("t")
received_sig = header_dict.get("v1")
if not timestamp or not received_sig:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Malformed signature header")
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Timestamp outside allowed tolerance")
signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}".encode('utf-8')
expected_sig = hmac.new(WEBHOOK_SECRET.encode('utf-8'), signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected_sig, received_sig):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid signature")
return True
Both examples follow the OWASP webhook security guidance's core recommendations: verify against the raw body, treat the timestamp as mandatory, and reject anything malformed with a generic error rather than a descriptive one.
Build it yourself, or use dedicated infrastructure
If you're sending webhooks to many customers rather than just receiving a few, the engineering surface is larger than it looks: generating and storing a secret (or key pair) per endpoint, formatting provider-specific headers, handling retries and backoff, and rotating secrets without downtime.
This is a real enough problem that dedicated webhook-infrastructure providers exist specifically to take it off your plate — Svix (open-source, and the maintainer of the Standard Webhooks spec) and Hookdeck are two commonly used examples, offering signing, retry/backoff logic, delivery logging, and receiver-side verification SDKs as a managed layer in front of your own event source. Whether that trade-off makes sense depends on your team's scale and how much of this you'd otherwise be re-implementing per integration — but it's worth knowing the category exists before hand-rolling per-customer secret storage and rotation logic from scratch.
Looking ahead: what post-quantum cryptography means for webhooks
This is a newer consideration, and worth a brief mention if you're choosing a signing scheme today. In August 2024, NIST finalized its first post-quantum cryptography standards — FIPS 203 (ML-KEM) for key exchange and FIPS 204 (ML-DSA) and FIPS 205 (SLH-DSA) for digital signatures — because sufficiently powerful quantum computers would be able to break RSA and elliptic-curve schemes (including Ed25519 and ECDSA) via Shor's algorithm. NIST's transition guidance (NIST IR 8547) calls for deprecating RSA-2048 and ECC P-256 for new deployments by 2030, with full removal from NIST-approved standards by 2035.
The practical implication for webhook signing specifically: asymmetric schemes — RSA, Ed25519, ECDSA — are the ones with a defined, if distant, expiration date. Symmetric HMAC is comparatively unaffected: the best-known quantum attack against a well-keyed hash function (Grover's algorithm) only offers a quadratic speedup, which is neutralized by simply using a sufficiently large key and hash output, something HMAC-SHA256 already provides headroom for. That's not a reason to dismiss Ed25519 today — the timeline is years away and it still solves a real problem HMAC doesn't (non-repudiation, no shared secret) — but if you're designing new signing infrastructure now, building in algorithm agility (keeping the algorithm and key configuration external to your business logic, so it's a config change rather than a rewrite later) is cheap insurance against a migration that's now on a published NIST timeline rather than a hypothetical one.
Summary
- HMAC-SHA256 remains the default for the highest-volume senders — Stripe, GitHub, and Svix all use it — because it's fast, simple, and well-supported everywhere. Its weakness is the shared secret itself.
- RSA solves the shared-secret problem but is CPU- and bandwidth-heavier, and is increasingly uncommon as a default choice for new webhook platforms.
- Ed25519 is the modern asymmetric option in active production use (Discord, Telnyx v2), balancing RSA's non-repudiation benefit against a much smaller performance and size cost. ECDSA (SendGrid) plays a similar role.
- Whichever scheme a provider uses, most real-world breaches trace back to the receiving side: verifying re-serialized JSON, non-constant-time comparisons, missing timestamp checks, brittle rotation, or — for asymmetric schemes — reusing one key pair across every tenant.
- The Standard Webhooks spec is worth watching if you're building new sending infrastructure, since it standardizes the header format and signed content across both symmetric and asymmetric implementations.
- Post-quantum standards give asymmetric webhook signing a long but real runway; symmetric HMAC is comparatively insulated from that particular migration pressure.
Sources and further reading
- Stripe — Resolve webhook signature verification errors
- Stripe — Receive events in your webhook endpoint
- GitHub Docs — Validating webhook deliveries
- GitHub Docs — Webhook events and payloads
- Standard Webhooks specification
- Svix — Webhook Security docs
- Svix — Common failure modes for webhook signatures
- Svix — svix-webhooks README (HMAC + Ed25519 support)
- webhooks.fyi — Asymmetric Key Signatures (EdDSA, ECDSA, RSA)
- Twilio SendGrid — Event Webhook Security Features (ECDSA)
- OWASP CheatSheetSeries — Webhook Security Guidelines (draft)
- GitLab Advisory Database — CVE-2026-21894 (n8n Stripe Trigger)
- NIST — FIPS 203/204/205 post-quantum standards migration guidance