HMAC vs Asymmetric Signature: Picking the Right Auth for APIs and Webhooks
Early in my career I spent half a day trying to verify a Stripe webhook by treating it like an RS256 JWT. I had just shipped a JWT-based auth flow the week before and the two problems looked similar in my head: there is a header, there is a body, there is a signature, the server checks the signature. After the third "invalid signature" error in a row, a colleague leaned over and asked which key I was loading. Stripe webhooks are HMAC, not RSA. The verification side does not need a public key at all; it needs the same shared secret Stripe used to sign. The bug was not in my code; it was in my mental model.
Picking between HMAC and an asymmetric signature feels like a cryptographic question. In practice it almost never is. Both options are secure when used correctly; the choice comes down to who needs to hold the key, and that answer is decided by the trust topology of the system, not by the algorithm. The rest of this guide walks through the two primitives, the trade-offs that actually matter, and the working patterns used by Stripe, GitHub, AWS, and JWT implementations.
The actual question: who needs to hold the key?
HMAC and asymmetric signing solve the same threat: an attacker who can intercept or modify a message in transit. Both produce a tag the receiver can check before trusting the body. The difference is what the receiver needs in order to do that check.
- HMAC uses a single shared secret. Both sender and receiver hold the same bytes. The signature can only be produced by someone who has that secret, and anyone holding the secret can also forge a signature. Trust is symmetric.
- Asymmetric signing (RSA, ECDSA, Ed25519) splits the key in two. The sender holds a private key; the receiver holds a public key. The public key can verify but cannot forge. Trust is one-way: the receiver can prove the message came from the holder of the private key, but no one else can produce a valid signature.
That one property decides everything else. If both parties are in your trust boundary and you have a secure way to share a secret out of band (you control both endpoints, or there is a single client and a single server), HMAC is faster, simpler, and easier to rotate. If one side cannot be trusted with the ability to sign — because the verifier is a browser, or because there are many verifiers and only one signer, or because the signer is a hardware wallet — asymmetric signing is the right answer.
HMAC in one paragraph
HMAC (Hash-based Message Authentication Code) is defined in RFC 2104. The construction takes a hash function (almost always SHA-256 in modern code, occasionally SHA-512) and a secret key, and produces a fixed-length tag for any message. The tag depends on both the message and the key, so an attacker who modifies one byte of the message cannot produce a tag that matches what the receiver computes — they would need the secret to do that.
// Node.js — produce and verify an HMAC-SHA256 tag
import { createHmac, timingSafeEqual } from 'crypto';
const secret = process.env.WEBHOOK_SECRET; // shared between the two parties
const body = '{"event":"order.created","id":42}';
// Producer side: compute the tag
const tag = createHmac('sha256', secret).update(body).digest('hex');
// e.g. "a3f4...c1b2" — 64 hex characters
// Verifier side: recompute the tag locally, compare in constant time
const expected = createHmac('sha256', secret).update(body).digest();
const received = Buffer.from(receivedTagHex, 'hex');
const ok = expected.length === received.length
&& timingSafeEqual(expected, received);Three details from this snippet deserve their own attention. The hash is part of the contract, so the verifier must use the same algorithm the producer did. The key bytes themselves are passed directly to createHmac rather than mixed into the body; this is what distinguishes HMAC from a plain "hash of secret + body" construction (which has a length-extension weakness in some hash families). And the comparison uses timingSafeEqual, not ===; a regular string comparison would short-circuit on the first mismatching byte and leak information about how much of the tag the attacker got right.
For a quick interactive check of HMAC values across SHA-256 / SHA-384 / SHA-512, BeautiCode's HMAC generator runs the computation in the browser using the Web Crypto API, which makes it useful when you are debugging a webhook signature mismatch and need to see what the "correct" tag for a given body and secret looks like.
Asymmetric signing in one paragraph
Asymmetric signing uses a private key to sign and a public key to verify. The two keys are a mathematical pair: anything signed with one can only be verified by the other, and knowing the public key tells you nothing useful about the private key. RSA (PKCS#1 v1.5 or PSS), ECDSA over P-256, and Ed25519 are the three families you will encounter in practice. Ed25519 is the modern default for new systems; RSA still dominates legacy infrastructure; ECDSA sits in the middle, common in TLS certificates and JWTs that target older verifiers.
// Node.js — sign and verify with an Ed25519 key
import { sign, verify, generateKeyPairSync } from 'crypto';
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
const body = Buffer.from('{"event":"order.created","id":42}');
// Producer: sign with the private key
const signature = sign(null, body, privateKey); // 64-byte signature for Ed25519
// Verifier: validate with the public key (no secret required on this side)
const ok = verify(null, body, publicKey, signature);Two operational consequences fall out of this. First, the public key is safe to distribute — you can publish it in a static JSON document or a DNS record without weakening security. Second, only the holder of the private key can produce a valid signature, so the signer needs strong key custody (HSM, vault, or at minimum a carefully scoped service identity). For new systems where you control both sides, Ed25519 is fast, has a small key size (32 bytes private, 32 bytes public, 64-byte signature), and has no parameter choices to get wrong; for systems that need to interop with older verifiers, RSA-2048 or ECDSA-P256 is what most libraries default to.
The dedicated guide on RSA vs Ed25519 goes deeper on key sizes, speed, and FIPS-bound use cases. To generate keys directly in the browser without ever exposing the private key to a server, use the RSA key generator or the Ed25519 key generator.
Side-by-side: where the trade-offs actually land
The choice between HMAC and asymmetric signing is rarely about which one is "more secure"; both are secure when applied correctly. The real trade-offs are operational.
| Dimension | HMAC | Asymmetric (RSA / Ed25519) |
|---|---|---|
| Key holders | Both sides hold the same secret | Signer holds private, verifier holds public |
| Verification cost | One hash; microseconds | Ed25519 ≈ tens of microseconds, RSA-2048 ≈ hundreds |
| Signing cost | Same as verification | Significantly slower; RSA signing is the slowest operation |
| Key distribution | Out of band, secure channel; key escrow is the same as compromise | Public key can be published openly (JWKS, DNS, static file) |
| Verifiers | One per shared secret | Unbounded; anyone with the public key can verify |
| Key rotation | Cut-over both sides; allow grace period with two valid secrets | Publish new public key alongside old; rotate signer at the source |
| Replay protection | Not built in; pair with timestamp + nonce + expiry | Not built in either; same caveat |
| Non-repudiation | None; verifier could have forged it | Yes; only the private key holder could have signed |
| Signature length | 32 bytes (SHA-256) or 64 bytes (SHA-512) | Ed25519: 64 bytes; RSA-2048: 256 bytes |
Two rows from that table are the ones that drive most real decisions. Key distribution: if you have a way to share a secret out of band (a config value in your CI, an environment variable in your serverless function, a setup screen in your dashboard), HMAC is operationally simpler. Verifiers: if anyone in the world might need to verify the signature (public webhooks, JWTs read by third-party services, federated OIDC), you need asymmetric.
Non-repudiation matters in narrower cases. If a contract dispute hinges on whether message X really came from party A, HMAC cannot answer it because either party could have produced the tag. Asymmetric signing can. For most webhook and API integration use cases, this distinction does not come up; for audit logs, supply chain provenance, and notarised systems, it is the entire point.
Real patterns: Stripe, GitHub, AWS, JWT
Looking at how the major API providers ship signing helps cement when each primitive wins.
Stripe webhooks — HMAC-SHA256
Stripe sends a Stripe-Signature header on every webhook, formatted as t=1700000000,v1=hexHmac. The t is the timestamp; the v1 is HMAC-SHA256 over `${t}.${rawBody}` using your endpoint signing secret. The reason it can be HMAC is that the trust topology is closed: only Stripe and your endpoint know the secret, the secret is set in your Stripe dashboard, and rotation is cheap (Stripe even lets you have two active secrets during a rollover).
// Node.js — Stripe-style webhook verification
import { createHmac, timingSafeEqual } from 'crypto';
function verifyStripeWebhook(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => p.split('='))
);
const t = parts.t;
const v1 = parts.v1;
// 1) Reject anything older than 5 minutes to limit replay window
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) {
return false;
}
// 2) Recompute the expected tag
const signed = `${t}.${rawBody}`;
const expected = createHmac('sha256', secret).update(signed).digest();
const received = Buffer.from(v1, 'hex');
// 3) Constant-time compare
return expected.length === received.length
&& timingSafeEqual(expected, received);
}The two non-cryptographic details — timestamp window and constant-time compare — are what separate a working implementation from one with a hole. The timestamp window prevents replay of an old captured webhook; without it, an attacker who recorded a valid signed message can reuse it forever. The constant-time compare prevents the attacker from binary-searching the correct tag byte by byte through timing analysis.
GitHub webhooks — HMAC-SHA256
GitHub uses the same primitive with a different envelope. The signature ships in X-Hub-Signature-256: sha256=hexHmac, computed over the raw request body using the webhook secret you set in the repository or organisation settings. There is no timestamp in the header, so replay protection has to come from elsewhere (idempotency keys on the receiver, or rejecting events older than a known cursor).
AWS Signature V4 — HMAC chained
AWS request signing also uses HMAC, but with a derivation chain: the date, the region, and the service are folded into successive HMAC operations to derive a per-request signing key. This keeps the long-lived secret access key away from any single signature and limits blast radius if a signature leaks. Same primitive, more layers, same reason HMAC is the right tool: only AWS and the client know the secret.
JWT HS256 vs RS256
JWTs (RFC 7519) support both. HS256 is HMAC-SHA256; RS256 is RSA-SHA256. The decision is the same trust-topology question.
- HS256: same service signs and verifies. Simple, fast, no key distribution. Use when the JWT is consumed only by services in your own infrastructure.
- RS256: signer is your auth server, verifiers are downstream services (or third parties) that fetch the public key from a JWKS endpoint. Use when the JWT will travel beyond your trust boundary, or when you want downstream services to be unable to forge tokens (a multi-tenant API gateway, an OAuth resource server).
The historical foot-gun here is the "alg: none" bug, where some libraries accept a JWT with no signature when the header says so. Modern libraries reject this by default, but if you are reviewing older auth code, confirm the verification routine explicitly checks the algorithm against an allow-list. For inspecting JWT contents without trusting them yet, BeautiCode's JWT decoder decodes the header and payload client-side without contacting a server.
Replay protection and key rotation are separate problems
Neither HMAC nor asymmetric signing tells you anything about freshness. A valid signature on a six-month-old captured request is still valid; the cryptography has no opinion on whether the message is current. Replay protection has to be added on top, and the two common patterns are:
- Timestamp in the signed payload, plus a maximum age window enforced by the verifier. Stripe's 5-minute window is a reasonable default; tighten it for high-value endpoints, widen it for receivers that might be temporarily offline.
- Nonce or idempotency key that the receiver stores after a successful process, and rejects on the second occurrence. Pairs well with timestamps to bound the storage requirement.
Key rotation deserves its own thought. The general pattern, regardless of HMAC or asymmetric, is to allow two keys to be valid at the same time during a rollover. For HMAC, the verifier checks against both secrets and accepts either; once the producer has migrated, the old secret is retired. For asymmetric, the verifier holds both public keys in its JWKS cache; once all in-flight signatures have rotated, the old key is removed. Skipping the overlap window is how outages happen during what should have been a routine rotation.
Wrap up: which one to reach for
The rule of thumb that fits almost every real situation:
1-to-1 trust, you control both endpoints, secret distribution is solvable → HMAC-SHA256.
1-to-many trust, public verifiers, third parties involved, non-repudiation matters → Ed25519 (or RSA-2048 for legacy compatibility).
The corollary is that switching from HMAC to asymmetric signing rarely improves security if the threat model hasn't actually changed. It mostly adds key management cost. The reverse is also true: if you find yourself shipping a public key to dozens of independent verifiers along with a private key in a shared config repo, you have asymmetric signing in form but HMAC in substance, with worse ergonomics and no extra security. The shape of the system should match the shape of the trust.
For hands-on experimentation, the HMAC generator and JWT decoder let you reproduce the byte-level math from any of the patterns above without writing a single line of code. The hash generator is useful for the plain SHA-256 of a payload when you want to confirm what the signer actually fed into the HMAC step.
Related Tools
HMAC Generator
Generate HMAC signatures using SHA-256, SHA-384, SHA-512 with a secret key.
JWT Decoder
Decode JSON Web Tokens to inspect header, payload, signature, and expiration time.
RSA Key Pair Generator
Generate RSA public and private key pairs in PEM format with 2048/4096 bit key sizes.
Ed25519 Key Generator
Generate Ed25519 key pairs, sign messages, and verify digital signatures with high-performance elliptic curve cryptography.
Hash Generator
Generate MD5, SHA-1, SHA-256, SHA-384, SHA-512 hashes from text with real-time computation.
Related Articles
How to Generate Secure Passwords in 2026: A Complete Guide
Credential attacks now lean on GPU clusters and ML pattern guessing. What entropy, length, and randomness actually buy you, plus the password manager picks that hold up in 2026.
2025-12-15 · 8 min readData FormatsJSON vs YAML: When to Use What — A Developer's Guide
JSON wins on APIs; YAML wins on configs. Side-by-side syntax, parser behaviour, and where each fits across Kubernetes manifests, REST payloads, and GitHub Actions.
2025-12-28 · 10 min read