HMAC vs digital signature is the most important design decision in any message authentication scheme, and it is about trust, not speed. An HMAC tag is computed and checked with the same secret key, so everyone who can verify can also forge. A signature is made with a private key and checked with a public key, so verifiers cannot create new valid messages.
At a glance
| HMAC (RFC 2104, FIPS 198-1) | Digital signature | |
|---|---|---|
| Keys | One shared secret | Private key signs, public key verifies |
| Who can create a valid tag or signature | Anyone holding the secret, including every verifier | Only the private key holder |
| Non-repudiation (provable to third parties) | No | Yes, given a trusted binding of key to signer |
| Key distribution | Secret must reach every verifier confidentially | Public key can be published (JWKS, certificate, DNS) |
| Size of the proof | 32 bytes for HMAC-SHA256 | 64 bytes (Ed25519, ECDSA P-256), 256–512 bytes (RSA) |
| Speed, one Apple M4 Max core | about 7 million tags per second for 256-byte messages (OpenSSL 3.6.5) | about 60,000 Ed25519 or 87,000 P-256 signatures per second |
| Typical uses | Webhooks, session cookies, AWS SigV4, TOTP, HS256 JWTs, TLS 1.2 record MACs | TLS certificates and handshakes, OIDC ID tokens, package signing, Git, passkeys |
| Post-quantum | Safe with SHA-256 (Grover only halves preimage effort) | Classical schemes broken by Shor; needs ML-DSA or similar |
HMAC throughput measured with openssl speed -hmac sha256 (about 1.83 GB/s with 256-byte inputs); signature rates with openssl speed.
The trust model is the difference
An HMAC is a keyed hash: H((K ⊕ opad) ‖ H((K ⊕ ipad) ‖ m)). Its security goal is that nobody without the key can produce a valid tag. That is enough when exactly two parties share the key and trust each other not to forge, because a valid tag then means "the other party sent this". With three or more parties it breaks down: any verifier can impersonate the sender to the others, and in a dispute the sender can claim the receiver made the tag.
A digital signature separates the abilities. Publishing the verification key does not help anyone sign, so one issuer can serve thousands of verifiers, and a signature can be shown to an auditor or a court as evidence of origin, provided the key was bound to the signer by a certificate, a key directory or an account record. That separation is why identity tokens consumed by many services (OpenID Connect ID tokens, access tokens checked by resource servers) use signatures, and why a leaked verifier configuration is harmless with signatures but catastrophic with HMAC.
Practicalities
Key management. HMAC secrets must be generated randomly (at least 256 bits for HMAC-SHA256; RFC 7518 §3.2 requires that for HS256), transmitted confidentially, stored by every verifier and rotated everywhere at once or with a grace period of two valid secrets. Signature keys keep the private half in one place, ideally an HSM or KMS, and rotate through key identifiers in a published key set.
Performance and size. HMAC is two hash computations: millions per second per core, with a 32-byte tag. Signatures cost tens of microseconds and are 64 bytes or more. Neither matters for most web traffic; it matters for per-packet authentication or very high-volume internal messaging, where HMAC or an AEAD is the norm.
Pitfalls. HMAC must be compared in constant time, must not be replaced by hash(key ‖ message) (which SHA-256's length extension breaks), and must authenticate exactly the bytes received (raw request body, not a re-serialized JSON object). Signatures must be verified with an algorithm the verifier pins, never one chosen by the message.
The script below, run with Node.js 26.10, authenticates the same webhook body both ways.
import { createHmac, generateKeyPairSync, randomBytes, sign, timingSafeEqual, verify } from "node:crypto";
const body = Buffer.from('{"event":"invoice.paid","id":"evt_123"}');
// HMAC: sender and receiver share one secret (in production it comes from a secret store).
const secret = randomBytes(32);
const tag = createHmac("sha256", secret).update(body).digest();
const expected = createHmac("sha256", secret).update(body).digest();
console.log("HMAC-SHA256 tag:", tag.length, "bytes, valid:", timingSafeEqual(tag, expected));
// Signature: sender keeps the private key, receivers hold only the public key.
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const signature = sign(null, body, privateKey);
console.log("Ed25519 signature:", signature.length, "bytes, valid:", verify(null, body, publicKey, signature));
HMAC-SHA256 tag: 32 bytes, valid: true
Ed25519 signature: 64 bytes, valid: true
The HMAC secret must be shared with every verifier; the Ed25519 private key never leaves the sender, and only the 32-byte public key is distributed.
Webhooks and APIs in practice
Most webhook providers use HMAC because each subscriber gets its own secret: GitHub sends X-Hub-Signature-256: sha256= followed by a hex HMAC-SHA256 of the body, and Stripe signs a timestamp plus the body in its Stripe-Signature header so receivers can reject replays. Because each subscriber's secret is unique, the multi-party problem does not arise. Signed webhooks, built on HTTP Message Signatures (RFC 9421) or JWS, appear when one sender talks to many receivers that should not share a secret, or when receivers want to verify without provisioning secrets. AWS Signature Version 4 is HMAC-based request signing; OAuth DPoP and Web Bot Auth use signatures because the verifier must not be able to impersonate the client.
When to choose which
- Same service issues and verifies (session cookies, CSRF tokens, internal JWTs): HMAC-SHA256.
- One sender, one receiver, secret shared out of band (per-customer webhooks): HMAC with a timestamp in the signed data.
- One issuer, many verifiers (identity tokens, public APIs, mobile apps verifying server data): signatures, with keys published in a JWKS.
- Evidence for third parties (contracts, audit logs, software releases): signatures plus trusted timestamps.
- Clients that must not be able to impersonate each other (DPoP, bot authentication, device attestation): signatures with per-client keys.
- Very high message rates on a trusted link: HMAC or an AEAD.
Migration notes
Moving from HMAC to signatures usually happens when a second verifier appears or a secret leaks.
- Introduce an algorithm identifier if your format lacks one (JOSE
alg, an HTTP signaturealgparameter, a version prefix), so verifiers can know which scheme a message uses without trusting it blindly. - Publish the public key with a key identifier (JWKS, HTTP signature key directory) and give verifiers time to fetch it.
- Dual-sign during the transition: send both the HMAC header and the signature, and let verifiers prefer the signature.
- Remove the shared secret from verifiers that only need to verify; this is the security gain, so do not keep it "just in case".
- Never let one key serve both schemes. An RSA public key reused as an HMAC secret is exactly the algorithm confusion that broke JWT libraries; see HS256 vs RS256.