HMAC-SHA256 vs RSA asymmetric webhook signatures

Choosing a signing scheme is the first irreversible decision you make when securing an outbound webhook channel, because it dictates how secrets are distributed, who can prove what about a delivered event, and how much CPU each verification burns. This comparison sits under the HMAC Signature Verification reference and weighs symmetric HMAC-SHA256 against RSA and ECDSA asymmetric signatures so you can pick deliberately rather than by default. If you have already settled on the shared-secret model, the companion walkthrough HMAC webhook validation in Node.js shows the verification pipeline end to end. This page focuses on the trade-offs that decide which scheme you should run in the first place.

The core distinction is key symmetry. HMAC-SHA256 uses one shared secret that both the sender and receiver hold, so the same value that creates a signature also verifies it. RSA and ECDSA use a key pair: the sender signs with a private key it never shares, and every receiver verifies with a public key that carries no signing power. That single property cascades into every other dimension below.

Symmetric vs asymmetric signing Top row shows a shared HMAC secret held by both sender and receiver; bottom row shows a private signing key kept only by the sender and a public verification key distributed to many receivers. Symmetric: HMAC-SHA256 Sender holds secret S Receiver holds secret S same secret S Asymmetric: RSA / ECDSA Sender private key (secret) Receiver A: public key Receiver B: public key public key (shared) public key cannot forge signatures
Key distribution contrast: HMAC shares one secret with every verifier, while asymmetric signing distributes only a non-forging public key.

Key distribution and the shared-secret blast radius

With HMAC-SHA256 the secret must reach every party that verifies signatures. For a single consumer this is trivial. The problem compounds as the number of receivers grows: if you fan one event stream out to ten internal services and each verifies independently, ten copies of the signing secret now exist, and a leak in any one of them lets an attacker forge events that all ten will accept. There is no way to grant verify-only capability with a symmetric key — possession of the verifying material is possession of the signing material.

Asymmetric signing breaks that coupling. The private signing key lives in exactly one place (the sender’s signer, ideally a KMS or HSM), and the public key can be published openly. A compromised receiver leaks only a public key, which grants an attacker nothing: they still cannot sign anything the other receivers will trust. This is the decisive advantage for multi-tenant platforms and any provider that signs events consumed by customers it does not control.

The cost is operational. Asymmetric schemes still require key rotation, and distributing rotated public keys to many consumers is its own problem — typically solved by a published JWKS endpoint. For the rotation mechanics that apply to both schemes, see Key Rotation Strategies.

Non-repudiation and what a signature actually proves

HMAC offers integrity and authenticity but not non-repudiation. Because both parties hold the same secret, either one could have produced a given signature. If a dispute arises over whether the sender truly emitted an event, an HMAC signature cannot settle it: the receiver had the means to forge it. For most internal integrations this is irrelevant — you trust both ends. For regulated financial or legal events where the recipient may later contest what was sent, it matters.

Asymmetric signatures provide non-repudiation. Only the holder of the private key could have produced a valid signature, so a verified RSA or ECDSA signature is cryptographic evidence that the sender — and only the sender — emitted that exact payload. This is why audit-grade webhook channels and inter-company event exchange lean asymmetric even though it costs more per verification.

The difference only becomes visible when a third party is asked to adjudicate. Trace the same disputed event through both schemes and the asymmetry of the outcome is immediate.

Adjudicating a disputed event A sender delivers a signed event to a receiver, the receiver submits it to an auditor, and the auditor asks whether only the sender could have produced the signature, which HMAC cannot answer and asymmetric signing can. Can a signature prove who sent the event? Sender Receiver Auditor event + signature disputed event submitted could only the sender have signed? HMAC: unanswerable — the receiver holds the same secret S RSA / ECDSA: yes — only the private key can produce it Only asymmetric signing settles the dispute; HMAC proves integrity, not authorship.
Non-repudiation is not a stronger form of integrity — it is a different property, and only a scheme where the verifier cannot sign delivers it.

Performance and verification cost

Performance is where HMAC dominates. An HMAC-SHA256 computation is a pair of hash passes — microseconds, and effectively free at any realistic webhook volume. RSA verification is cheap relative to RSA signing but still orders of magnitude slower than HMAC, and RSA signing with 2048-bit keys is genuinely expensive. ECDSA narrows the gap considerably: signing is fast and keys are tiny (a P-256 key is 32 bytes versus hundreds for RSA), at the cost of slower verification than HMAC and a hard requirement for a secure random nonce per signature.

Property HMAC-SHA256 RSA-2048 (PSS) ECDSA (P-256)
Key model One shared secret Private + public key pair Private + public key pair
Verifier capability Can also forge Verify only Verify only
Non-repudiation No Yes Yes
Sign speed Fastest Slow Fast
Verify speed Fastest Fast Moderate
Signature size 32 bytes 256 bytes 64 bytes
Best fit Single/trusted consumer Audit-grade, many consumers Audit-grade, size-sensitive

Verifying each scheme in TypeScript

The verification code makes the API difference concrete. HMAC verification recomputes the digest with the shared secret and compares in constant time:

import crypto from 'node:crypto';

export function verifyHmac(rawBody: Buffer, signatureHex: string, secret: string): boolean {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
  const provided = Buffer.from(signatureHex, 'hex');
  // Length check guards timingSafeEqual against RangeError and length leaks.
  return expected.length === provided.length && crypto.timingSafeEqual(expected, provided);
}

Asymmetric verification needs only the public key — note there is no shared secret anywhere in the receiver’s code, which is exactly the point:

import crypto from 'node:crypto';

// publicKeyPem is safe to ship to every consumer; it cannot sign anything.
export function verifyRsa(rawBody: Buffer, signatureB64: string, publicKeyPem: string): boolean {
  const verifier = crypto.createVerify('RSA-SHA256');
  verifier.update(rawBody);
  verifier.end();
  return verifier.verify(
    { key: publicKeyPem, padding: crypto.constants.RSA_PKCS1_PSS_PADDING },
    Buffer.from(signatureB64, 'base64'),
  );
}

// ECDSA verification differs only in the key type and signature encoding.
export function verifyEcdsa(rawBody: Buffer, signatureB64: string, publicKeyPem: string): boolean {
  const verifier = crypto.createVerify('SHA256');
  verifier.update(rawBody);
  verifier.end();
  return verifier.verify(
    { key: publicKeyPem, dsaEncoding: 'ieee-p1363' },
    Buffer.from(signatureB64, 'base64'),
  );
}

crypto.verify is constant-time with respect to the secret material by construction, so unlike HMAC you do not manage timingSafeEqual yourself. The trade-off is that you must pin the padding (RSA_PKCS1_PSS_PADDING, not legacy PKCS#1 v1.5) and the ECDSA signature encoding (ieee-p1363 versus ASN.1 DER) to exactly match what the sender emitted, or every verification silently fails.

Choosing a scheme

Reach for HMAC-SHA256 when there is a single trusted consumer, the channel is internal, and you want the simplest possible verification with negligible CPU cost. Reach for asymmetric signing — ECDSA P-256 as the default, RSA-PSS where a counterparty mandates RSA — when events leave your trust boundary, when many independent consumers verify the same stream, or when you need non-repudiation for audit or compliance. A common middle path is HMAC internally with an asymmetric outer signature only on the public-facing edge, isolating the expensive scheme to the boundary where its guarantees actually pay off.

Resolved as a sequence of tests, the choice collapses quickly — most channels terminate on the first question.

Signing scheme decision path Three sequential questions on trust boundary, verifier count and signature size constraints, each resolving to HMAC, HMAC with mutual TLS, ECDSA P-256 or RSA-PSS. Signing scheme decision path Do events cross your organization boundary? no HMAC-SHA256 one shared secret, fastest path yes Do many independent parties verify the stream? no HMAC-SHA256 + mutual TLS transport-level sender identity yes Is signature size or signing cost constrained? yes ECDSA P-256 64-byte signature, fast signing no RSA-2048 with PSS padding when a counterparty mandates RSA
Each question is a hard test with an observable answer, so the scheme is decided by the channel's shape rather than by preference.

Migrating an existing channel from HMAC to asymmetric signatures

Switching schemes on a live channel is a dual-emission problem, not a cutover. The receiver must be able to verify with either scheme throughout, exactly as with a dual-secret rotation window, and the ordering below exists so that no consumer is ever asked to verify with a key it has not already cached.

  1. Publish the verification key before signing with it. Stand up a JWKS endpoint carrying the new public key and let consumers cache it for at least one full cache TTL before any event is signed asymmetrically.
  2. Emit both signatures during the overlap. Send the existing HMAC header alongside a new asymmetric signature header so consumers can verify with either scheme while they migrate.
  3. Measure consumer adoption per scheme. Have consumers report which header they verified, or infer it from acknowledgement metadata, until every active subscriber verifies asymmetrically.
  4. Retire the shared secret. Stop emitting the HMAC header, delete the shared secret from every consumer’s configuration, and revoke it in the secrets manager.

During the overlap both headers are present, so a consumer that verifies only the HMAC header keeps working unchanged. Receivers implementing the symmetric side of this can follow the HMAC webhook validation walkthrough in Node.js; if the asymmetric header fails to verify while the HMAC header succeeds, the cause is almost always an encoding mismatch rather than a key problem, and the byte-level triage in debugging HMAC signature mismatches applies to both schemes.

Failure modes and gotchas

Frequently Asked Questions

We already require mutual TLS on the webhook connection. Does the signing scheme still matter?

Mutual TLS authenticates the connection, not the event, and its guarantee ends at the socket. Once the payload is written to a queue, a log, or an internal forwarder, nothing about those bytes is still attributable to the sender, so a downstream consumer cannot distinguish a genuine event from one injected inside your network. A signature travels with the payload and can be re-verified at any hop, or months later from storage.

Could we get non-repudiation from HMAC by having a neutral third party hold the secret?

Escrow makes the dispute worse rather than better, because three parties can now produce a valid signature instead of two. Any construction that gives a verifier the ability to sign forfeits non-repudiation by definition, and the usual workarounds, such as notarised logs or countersigned receipts, amount to rebuilding asymmetric signing with more moving parts. If a third party may ever need to adjudicate, start with a key pair.

Where does Ed25519 fit next to RSA-PSS and ECDSA P-256?

Ed25519 is a sound default wherever both ends support it: signatures are 64 bytes as with P-256, signing is fast, and the per-signature nonce is derived deterministically from the key and the message, which removes the catastrophic failure mode of an ECDSA nonce drawn from a weak random source. Node verifies it through crypto.verify with a null algorithm argument and the public key alone. The constraint is ecosystem support rather than cryptography, since some HSMs, older JVM stacks, and compliance profiles still enumerate only RSA and the NIST curves.

How long should consumers cache the JWKS document?

Long enough that a key fetch never sits on the hot path of a delivery, which in practice means minutes rather than seconds, with the cached copy served stale if a refresh fails. Pair the TTL with a single rate-limited refetch triggered by an unrecognised key id, so a planned rotation converges in seconds instead of waiting out the TTL. Resolve that key id only against the issuer endpoint you configured, never against a URL carried in the request.

Does asymmetric verification cost enough to matter at real webhook volume?

For most receivers key parsing dominates the arithmetic: importing a PEM string on every request costs far more than the verification itself. Create the KeyObject once at startup or cache it per key id, and a single core still verifies thousands of events per second. Remember that the sender carries the heavier half of the asymmetry, since RSA signing is the expensive operation rather than RSA verification.

What should a receiver do with a signature whose key id it does not recognise?

Reject the delivery, but emit a distinct reason label so an unknown key id is visible separately from a failed verification. Allow at most one rate-limited JWKS refresh per unknown key id; without that limit a forged header carrying random key ids turns your receiver into a request amplifier aimed at your own issuer. If the id still does not resolve after the refresh, the sender is either rotating ahead of its published document or is not who it claims to be.

Can we emit both signatures permanently instead of finishing the migration?

You can, but the channel then inherits the weaker of the two properties: the shared secret still exists on every consumer, so any of them can still forge an event the others will accept, and the non-repudiation you migrated for never actually arrives. Permanent dual emission also doubles the material that must be rotated and keeps two verification paths alive to drift apart. Treat the overlap as a bounded window with a named owner and a deadline.