Under the Hood
Crypto

MACs and digital signatures

A MAC and a digital signature both prove a message is intact and came from someone who holds a specific key, but only a signature — built on a key pair rather than a shared secret — can prove which specific party sent it, and confusing the two is a common design mistake.

MACs and digital signatures

Lesson 1 drew a line between integrity ("was this tampered with?") and authenticity ("did it really come from who it claims?"), and named two primitive families that deliver both together: MACs and digital signatures. It's tempting to treat them as interchangeable — both take a message and a key and produce a tag you can check — but they rest on entirely different key setups, and that difference decides what each one can actually prove. Mix them up and you can end up with a system where anyone who can verify a message could also have forged it, which is a much worse bug than it sounds.

The frame for this lesson: a MAC uses a shared secret key, so it proves the message is intact and came from someone holding that key — but since the key is shared, it can't prove which holder sent it. A signature uses a key pair, so only the private-key holder could have produced it, which proves integrity, authenticity, and non-repudiation.

The MAC: one shared key, checked both ways

A MAC (Message Authentication Code) is a tag computed from two inputs: the message, and a secret key both parties already share. Whoever holds the key can run the same computation and check the tag matches.

The standard construction is HMAC (hash-based MAC), which wraps a hash function (lesson 2) with the shared key in a specific, carefully designed way — not just hash(key + message), which has subtle weaknesses depending on the hash's internal structure, but a construction (hash the key and message together twice, with different padding each time) proven to be secure as long as the underlying hash is. You never build this by hand; you call HMAC-SHA256 and get the vetted construction.

async function hmacKey(rawKeyBytes) {
  return crypto.subtle.importKey(
    "raw",
    rawKeyBytes,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign", "verify"]
  );
}

async function sign(key, message) {
  const bytes = new TextEncoder().encode(message);
  const tag = await crypto.subtle.sign("HMAC", key, bytes);
  return new Uint8Array(tag);
}

async function verify(key, message, tag) {
  const bytes = new TextEncoder().encode(message);
  // constant-time comparison happens inside verify — see below
  return crypto.subtle.verify("HMAC", key, tag, bytes);
}

Here's what a valid HMAC tag proves, precisely: the message hasn't been altered since the tag was computed (integrity), and whoever computed the tag held the shared key (authenticity, in the sense of "someone with the key"). Both properties come from the same fact — you can't produce a correct tag for a message without the key, and the avalanche effect (lesson 2) means altering even one bit of the message produces a completely different tag, so tampering is caught.

What a MAC cannot prove

Here's the gap. Suppose Alice and Bob share an HMAC key so they can authenticate messages between them. Alice gets a message with a valid tag. She knows it came from someone holding the shared key — but she also holds that key. So did Bob send it, or did Alice's own system produce it, or does anyone else who obtained the key? A valid MAC can't distinguish "Bob sent this" from "Alice could have sent this to herself." Because the key is symmetric, anyone who can verify a MAC could also have forged one — verification and forgery use the identical key.

That's why a MAC gives no non-repudiation. If Bob later claims "I never sent that," Alice has no way to prove otherwise to a third party — she holds the same key Bob does, so the tag is equally consistent with either of them having produced it. For two parties who trust each other and just want tamper-evidence between themselves, that's completely fine. It stops being fine the moment you need to prove authorship to someone outside that shared-key relationship — a judge, an auditor, a third server that wasn't part of the key exchange.

Digital signatures: proving which key-holder

A digital signature replaces the shared key with the key pair from lesson 5: a private key only the signer holds, and a public key anyone can have. The signer signs with the private key; anyone verifies with the public key.

In practice, you don't sign the raw message directly — you sign its hash. Two reasons: asymmetric operations are slow (lesson 5), so signing a small fixed-size digest instead of an arbitrarily large message is far cheaper, and signature algorithms mathematically require a fixed-size input, so an unbounded message has to be reduced to one first anyway. The hash's collision resistance (lesson 2) matters enormously here — if an attacker could find a second message with the same hash, they could swap in a different message under an already-valid signature. This is exactly why lesson 2 flagged SHA-1 as unsafe for signatures specifically.

// Conceptual: sign with a private key, verify with the public key.
// (crypto.subtle supports ECDSA/RSASSA-PSS with generateKey/sign/verify.)
const { privateKey, publicKey } = await crypto.subtle.generateKey(
  { name: "ECDSA", namedCurve: "P-256" },
  true,
  ["sign", "verify"]
);

const signature = await crypto.subtle.sign(
  { name: "ECDSA", hash: "SHA-256" },
  privateKey,
  messageBytes
); // hashing happens internally as part of the sign call

const ok = await crypto.subtle.verify(
  { name: "ECDSA", hash: "SHA-256" },
  publicKey,
  signature,
  messageBytes
);

Because only the signer possesses the private key, a signature that verifies correctly could only have come from them — nobody else, not even someone who has the public key and can verify all day, can produce a new valid signature. That asymmetry is exactly what a MAC lacks, and it's what buys non-repudiation: the signer can't credibly claim someone else produced it, because nobody else could have.

Where each one actually shows up

HMAC is everywhere two parties already share a secret and don't need to prove anything to outsiders: signing API requests (a webhook provider and your server share a signing secret, and you HMAC the payload to confirm it wasn't forged or altered in transit), session tokens and signed cookies (the server holds the only key, so it's both signer and verifier — no non-repudiation needed since there's only one party who could have signed it), and integrity-checking messages between two systems under one operator's control.

Signatures show up whenever a third party — someone who wasn't part of the original key setup — needs to verify authorship independently: TLS certificates (lesson 7 and the TLS lesson — a CA signs a certificate, and any browser, not just one that shares a secret with the CA, can verify it), signed software packages and releases (so any user can verify the publisher produced a given binary), and JWTs.

That JWT case is worth pulling apart because it's where this exact confusion causes real bugs. A JWT can be signed two ways: HS256 uses HMAC with a shared secret — fine when there's exactly one verifier and it's the same party (or a tightly controlled set of parties) that issued the token. RS256 or ES256 use a real signature with a key pair — the issuer signs with its private key, and any number of independent services can verify with the public key without ever holding anything secret. Mixing these up has bitten real systems: if a service expects RS256 (public-key verification) but naively accepts whatever algorithm the token header claims, an attacker can craft an HS256 token and sign it using the public key as if it were an HMAC secret — because the public key is, by definition, public. The verifier, told "trust the algorithm in the header," ends up trusting a forgery. The fix is to pin the expected algorithm in verification code and never take it from the token itself — but the deeper lesson is to be deliberate about which of the two families you're actually using and why.

Verify in constant time

One implementation detail applies to both: when you compare a computed tag or signature against the one you received, use a constant-time comparison — one that takes the same amount of time regardless of where the first mismatched byte is. An ordinary === or byte-by-byte loop that returns early on the first difference leaks how many leading bytes matched through timing, and an attacker who can make many attempts and measure response time can use that leak to guess a valid tag one byte at a time. This is exactly the kind of implementation pitfall lesson 1's "don't roll your own crypto" was about — crypto.subtle.verify and vetted MAC/signature libraries already do this correctly; a hand-rolled tag === expectedTag does not.

Where this goes next

MACs and signatures both need a key to already exist — a shared secret for HMAC, a key pair for signatures. But how do two parties who've never met agree on a shared key in the first place, over a connection an eavesdropper is reading? That's the problem key exchange solves, and it's the piece that makes the rest of this module's primitives usable between strangers on the open internet.

Go deeper

  • MDN — SubtleCrypto.sign() The browser API surface for both HMAC and signature algorithms (ECDSA, RSASSA-PSS), with the exact parameter shapes used in this lesson's code.
  • Crypto 101 Covers MAC construction (including why naive hash-then-concatenate schemes fail) and signature schemes in more mathematical depth than this lesson.
  • RFC 8446 — TLS 1.3 Section 4.4 shows exactly where signatures (CertificateVerify) and MACs (Finished/record protection) each appear in a real protocol — the synthesis lesson 8 walks through.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. What two inputs does a MAC take, and what does a valid tag prove about the message and about who produced it?
  2. Explain precisely why a MAC cannot provide non-repudiation, using the fact that the key is shared.
  3. Why do signature schemes sign the hash of a message rather than the message itself? Give both the performance reason and the technical requirement.
  4. Why does a digital signature provide non-repudiation when a MAC doesn't? Point to the specific asymmetry between the two key setups.
  5. In the HS256 vs RS256 JWT confusion, what does the attacker exploit, and what specific verification mistake makes it possible?
  6. Give one real use case where HMAC is the right choice and one where a signature is required, and explain what property of each case forces the choice.
  7. Why must MAC and signature verification run in constant time, and what specifically leaks if it doesn't?