HMAC vs Hash: When to Use Each for Authentication and Integrity

Rahmat Ullah profile photoRahmat Ullah
13 min readSecurity, Developer Tools, Authentication

Every developer who has implemented webhook verification has, at some point, written something close to this:

if ($incomingSignature === hash('sha256', $body . $secret)) {
    // accept the request
}

It looks reasonable. The body is signed with the secret, the server recomputes the same hash, the strings match. Ship it. The problem is that this code is forgeable. An attacker who has captured one legitimate signed request can compute a valid signature for a longer message they make up themselves, without ever knowing the secret. The fix is one function call (hash_hmac instead of hash), but you have to know to make it. This article is the long version of why that fix exists.

A hash answers one question: "are these bytes the same as those bytes." HMAC answers a different question: "are these bytes the same AND did they come from somebody who knows the secret." Those are different questions. Many real production breaches come down to a developer reaching for the first when they needed the second.

What a Hash Actually Proves

A cryptographic hash function is a one-way mathematical map from any input to a fixed-size output. Same input, same output, every time, on every machine. Any change to the input (a single flipped bit) produces a wildly different output. There is no shared key. There is no secret. There is just the function and the bytes.

That gives you exactly one useful property: integrity. If I send you a file and a SHA-256, you can recompute the SHA-256 of the file you received and confirm it matches mine. If even one byte was changed in flight, the hashes will not match. You will know the file was modified.

What a hash does not give you is authentication. If somebody intercepts both the file and the SHA-256 in flight, and they replace both with their own file and the matching SHA-256 of their file, you have no way to tell. The hash proves the bytes you are looking at are the bytes whose hash you are looking at. It says nothing about who put them there.

This is exactly the gap the file download verification post walks through in detail: a published SHA-256 next to a download link does not actually prove the download is genuine, because both can be replaced by whoever controls the page. The integrity guarantee is real. The authenticity guarantee is not.

When you need to know both "are these bytes unmodified" and "did they come from someone I trust," a plain hash is not enough. You need a secret in the loop. You need HMAC.

Why You Need HMAC

HMAC stands for Hash-based Message Authentication Code. The "code" part is the important word: it produces a short tag (32 bytes for HMAC-SHA-256) that can only be computed by somebody who knows the secret. Anyone can verify the tag using the same secret. Without the secret, the tag is unforgeable in any practical sense.

You might wonder why the naive construction (hash(secret || message) or hash(message || secret)) is not enough. Both look like they prove "I knew the secret." Neither does, for different reasons.

Secret prepended (hash(secret || message)) is vulnerable to a length extension attack on Merkle-Damgard hashes (MD5, SHA-1, all of SHA-2). Given the hash of secret || message and the length of the secret, an attacker can compute the hash of secret || message || padding || attacker_data without ever seeing the secret. We walk through how this works below.

Secret appended (hash(message || secret)) does not have the length extension problem, but it is vulnerable to a different attack. If two different messages happen to collide under the underlying hash, they produce the same authentication tag, regardless of the secret. Hash collisions in SHA-256 are not currently practical, but the design is fragile in a way HMAC is not.

HMAC, defined in RFC 2104 by Bellare, Canetti, and Krawczyk in 1996, was designed specifically to be safe against both of these attacks. It uses the hash twice, with two different key derivations, in a way that makes both attacks irrelevant. The construction is provably secure under standard assumptions about the underlying hash, even if that hash has subtle weaknesses.

How HMAC Works Inside

The HMAC construction is short enough to write in one line:

HMAC(K, m) = H( (K' XOR opad) || H( (K' XOR ipad) || m ) )

Where:

  • H is the underlying hash function (SHA-256 in modern use)
  • K' is the secret key, either padded with zeros to the hash's block size or first hashed if it is longer than the block size
  • ipad is the byte 0x36 repeated to the block size
  • opad is the byte 0x5C repeated to the block size
  • || is concatenation

Two passes through the hash function. The inner pass derives an authentication tag over the message under one transformation of the key. The outer pass wraps that tag under a different transformation of the same key. The wrapping is what makes the construction safe.

Why two transformations and not one? Because that is what kills the length extension attack. An attacker who knows the output of the inner hash could extend it, but the outer hash is computed over data they cannot complete without the key. The outer hash is the gate. The inner hash is just the message digest.

The specific values 0x36 and 0x5C are not magic numbers in the cryptographic sense. They were chosen because they differ in many bit positions, which makes the two key derivations maximally independent. Any pair of bytes that differs strongly would have worked; these were picked and standardized.

For SHA-256, the block size is 64 bytes, so ipad and opad are 64 bytes long. The key is padded out to 64 bytes (with zeros) before being XORed with each pad. If the key is longer than 64 bytes, it is hashed first to bring it down to 32 bytes, then zero-padded out to 64. This means an HMAC key can be any length, but anything beyond the block size gets compressed first.

The Length Extension Attack

This is the attack that makes HMAC necessary. It is also a beautiful piece of mathematics that explains why "just append the secret" is the wrong instinct.

Every Merkle-Damgard hash (MD5, SHA-1, SHA-256, SHA-512) works the same way internally. It starts with a fixed initial state. It processes the input in blocks, mixing each block into the state with a compression function. When the input is exhausted, it appends a specific padding (length-encoded) and processes one final block. The hash output IS the final state.

That last sentence is the problem. If the output is the state, an attacker who has the output also has the state. They can resume hashing from where the original computation left off, by appending the original padding and then their own bytes. The result is a valid hash of (original_input || padding || attacker_input), computed without ever knowing what was in original_input.

Applied to hash(secret || message): if the attacker has the tag and knows the length of the secret (often guessable, sometimes published), they can produce a valid tag for secret || message || padding || ";admin=true". They never see the secret. They forge a signature anyway.

This is not theoretical. In 2009, Flickr's API signing used a raw SHA-1 of the request parameters with the API key prepended. A researcher demonstrated they could extend any captured signed request to add arbitrary parameters. Netflix had the same flaw in their subscription API around the same time. Both were patched by switching to HMAC. The vulnerability shape was identical: developers had reached for the obvious-looking construction and gotten bitten by an obscure property of the hash function.

HMAC defends against length extension because the attacker only ever sees the outer hash. The inner hash is never exposed. Extending the outer hash would let an attacker compute HMAC(K, m) || ..., but the verifier is checking HMAC(K, m') for some new message, which requires running the inner hash over m', which requires the key.

SHA-3 is immune to length extension by a different mechanism: it uses a sponge construction where the internal state is larger than the output, so the output is only a window into the state. You cannot resume hashing from a window. This is one reason SHA-3 is sometimes described as "immune by design." For the deeper hash-construction story, the MD5 vs SHA-256 vs SHA-512 post covers the Merkle-Damgard versus sponge tradeoff in detail.

HMAC by Hand

You do not write your own HMAC in production. Every standard library has it. But computing one once on paper makes the construction concrete. Here is the same input in four languages.

// Node.js
import { createHmac } from 'node:crypto'
const tag = createHmac('sha256', 'my-shared-secret')
  .update('POST /orders\namount=1000')
  .digest('hex')
// 4d4f1ad8...
# Python
import hmac, hashlib
tag = hmac.new(
    b'my-shared-secret',
    b'POST /orders\namount=1000',
    hashlib.sha256
).hexdigest()
# 4d4f1ad8...
// PHP
$tag = hash_hmac('sha256', "POST /orders\namount=1000", 'my-shared-secret');
// 4d4f1ad8...
// Go
import "crypto/hmac"; import "crypto/sha256"
mac := hmac.New(sha256.New, []byte("my-shared-secret"))
mac.Write([]byte("POST /orders\namount=1000"))
tag := hex.EncodeToString(mac.Sum(nil))
// 4d4f1ad8...

Same secret, same message, same output. That deterministic property is what makes HMAC useful as a verification primitive. Both ends of the conversation produce the same tag from the same inputs. Different secret, different output. Different message by one byte, different output.

If you want to play with this yourself, you can compute the underlying SHA-256 of any string using the hash generator. The HMAC computation involves two SHA-256 calls (over the key-derived prefix and the message), so a hands-on hex walk through is illuminating once you have done the math on paper.

Where HMAC Shows Up in Practice

If you have written any production code in the last decade, you have used HMAC, often without noticing. The same primitive shows up in many costumes.

Webhook signing

Stripe, GitHub, Slack, Twilio, Shopify, and basically every modern SaaS that sends webhooks signs them with HMAC-SHA-256. The pattern is consistent:

// Pseudo-verification for an incoming Stripe-style webhook
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $webhookSecret);
if (! hash_equals($expected, $providedSignature)) {
    return response('Invalid signature', 401);
}

The timestamp is included to prevent replay (same signature, replayed later). The raw body is signed (not the parsed JSON) because parsing is not bit-exact. The comparison uses a constant-time function, which we will come back to.

JWT HS256

The HS256 algorithm in a JWT header literally means "HMAC using SHA-256." The signature part of an HS256 JWT is base64url(HMAC-SHA-256(header.payload, secret)). The JWT explainer post covers the full lifecycle, but the cryptographic core is one HMAC call per token.

API request signing

AWS Signature Version 4 is HMAC all the way down. The signing key is derived from your secret access key by chaining HMAC-SHA-256 over the date, region, and service. The actual request signature is then an HMAC of the canonical request string under that derived key. Multiple HMAC passes, but every one of them is the same primitive.

Session cookies and signed URLs

Laravel, Django, Rails, and Express all sign their session cookies with HMAC so the server can verify them on every request without a database lookup. The same goes for pre-signed S3 URLs, password reset links, email confirmation links, and Laravel's signed routes. The pattern is the same: payload plus signature, server recomputes signature, accepts if they match.

One-time passwords (HOTP and TOTP)

The six-digit code from your authenticator app is HMAC under the hood. HOTP (RFC 4226) computes HMAC-SHA-1 of a counter under a shared secret, then truncates the output to a number. TOTP (RFC 6238) does the same with a time window instead of a counter. The reason your phone and the server agree on the code is that they are both running the same HMAC on the same inputs.

The shape that connects them

Every one of those is the same pattern: a payload, a secret known to both parties, and an HMAC tag that proves the payload was constructed by somebody with the secret. The tag is short, fast to compute, fast to verify, and unforgeable without the key. If you internalize the pattern, you stop seeing webhook signatures and session cookies and OTP codes as different things. They are all HMAC, dressed differently.

Common Mistakes

1. Comparing signatures with ==

The single most common HMAC mistake in production. === in PHP or === in JavaScript shortcircuits on the first differing byte, which lets an attacker measure response times and reconstruct the signature one byte at a time. This is a real attack, demonstrated against real services.

The fix is constant-time comparison: hash_equals in PHP, crypto.timingSafeEqual in Node, hmac.compare_digest in Python, subtle.ConstantTimeCompare in Go. Use the standard library primitive. Every modern stdlib has one.

2. Weak secrets

HMAC's security rests entirely on the secret being unpredictable. A short or guessable secret can be brute-forced offline against any single signed message. Generate secrets from a cryptographic RNG, make them at least 32 bytes (256 bits) for HMAC-SHA-256, and store them in a secret manager, not in source. Treat them with the same care as private keys.

3. Signing the wrong thing

The signature has to cover everything the verifier cares about, in a canonical, unambiguous form. Sign the raw request body, not the parsed and re-serialized JSON. Include the timestamp to prevent replay. Include the HTTP method and path if you are signing a request. If you forget a field, attackers can vary that field freely while keeping the signature valid.

4. Including the signature in what gets signed

Some developers concatenate the signature with the message before signing. This obviously cannot work (you cannot compute the signature before you have the signature), and the workaround usually involves zeroing it out, which is brittle. Sign the message; send the signature separately, in a header or trailing field.

5. Trusting source IP instead of the signature

"We trust Stripe webhooks because they come from Stripe's IP range." The IP range can change, intermediate proxies obscure the source, and the webhook URL is often publicly known or guessable. The signature is the only thing that proves the request really came from the legitimate sender. Always verify it on every inbound webhook.

6. Using MD5 or SHA-1 in 2026

HMAC-MD5 and HMAC-SHA-1 are not catastrophically broken (the HMAC construction provides some buffer against the underlying hash's weaknesses, which is one reason they survived in TLS so long). They are also no longer necessary. Use HMAC-SHA-256 as the default for new systems. HMAC-SHA-512 if you want a larger tag for long-term archives. The story of why SHA-1 is being phased out is in the hash algorithms post.

7. Using HMAC where you needed a password hash

HMAC is a fast, deterministic primitive. That makes it perfect for signing messages and terrible for hashing passwords. Passwords need a slow, memory-hard function (bcrypt, scrypt, Argon2) so attackers cannot brute-force the original password from a leaked hash table. The bcrypt post covers why password hashing is its own category and why HMAC is the wrong tool for it.

Common Questions

Is HMAC an encryption algorithm?

No. HMAC is a Message Authentication Code. The message is not hidden; an HMAC tag is computed over data sent in clear, and anyone watching the traffic can read both. The tag only proves the message came from somebody with the key. If you need confidentiality too, you encrypt the message and then HMAC the ciphertext, or use an authenticated encryption mode like AES-GCM that does both at once.

What is the difference between HMAC and a digital signature?

HMAC is symmetric: the same secret signs and verifies. Digital signatures (RSA, ECDSA, Ed25519) are asymmetric: a private key signs, a public key verifies. HMAC is faster and simpler. Signatures are required when you cannot safely share a secret with the verifier, which is exactly the case for things like federated identity, code signing, and signed documents.

How long should an HMAC secret be?

At least as long as the hash output. For HMAC-SHA-256, that means at least 32 bytes (256 bits). Anything shorter wastes the security margin. Longer keys are accepted but are pre-hashed down to the hash output size, so going far beyond the block size gives no additional security.

Is HMAC slow?

No. HMAC is essentially two hash operations. On modern hardware, HMAC-SHA-256 of a typical webhook body runs in microseconds. It is fast enough to use on every API request without thinking about it.

Can I rotate HMAC secrets without downtime?

Yes, with care. Run the verifier with two accepted secrets (the previous and the new) for the rotation window. Switch the signer to the new secret. Once you are confident no inflight requests are using the old secret, remove it from the verifier. This is the same expand-and-contract shape that works for any rotation.

What if I leak an HMAC output?

It reveals nothing about the secret on its own; HMAC is one-way. The concern is replay. A leaked tag plus its message can be replayed by an attacker against the verifier. Including a timestamp or a nonce in the signed message, and rejecting old or repeated values, is the defense.

Wrapping Up

Hashes prove integrity. HMACs prove integrity and origin. The distinction matters in every system that accepts signed input from somewhere it does not implicitly trust. Webhooks, signed URLs, session cookies, JWTs, one-time passwords, AWS request signatures: all the same primitive in different costumes.

The rule that catches the most bugs is short. If your code does hash($payload . $secret) and uses the result to decide whether to trust the payload, you are almost certainly looking at an HMAC waiting to happen. Use the standard library function (hash_hmac, crypto.createHmac, hmac.new), use HMAC-SHA-256 unless you have a specific reason not to, and compare the resulting tag with a constant-time comparison. The fix is one line of code. The vulnerability it prevents is real, well-documented, and still being exploited.

If this was useful, the natural follow-ups are the JWT explainer for how HMAC HS256 fits into the wider auth story, the MD5 vs SHA-256 vs SHA-512 comparison for the underlying hash decisions, and the bcrypt explainer for the very different world of password storage. And if you want to compute some SHA-256 by hand to internalize what the hash side looks like, the hash generator runs in your browser.