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.

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.