SHA-256 vs HMAC-SHA256: Differences and Code Examples
· by webmaster
SHA-256 and HMAC-SHA256 produce output that looks identical: 64 hex characters. However, they answer different questions. A SHA-256 hash tells you whether data changed. An HMAC tells you whether data changed and whether it came from someone who holds the secret. That second part is the whole difference, and it decides which one you use. This guide covers what each does, when each is right, and how to compute and verify both in JavaScript, PHP and Python. Every sample was run, and the outputs shown are real.
To check your own output as you go, use the SHA Hash Generator and HMAC Generator. Both run in your browser and never send the input anywhere.
A hash is a fixed-size fingerprint of any input
A cryptographic hash function takes input of any length and returns a fixed-size digest. SHA-256 returns 256 bits, written as 64 hex characters. Three properties make it useful:
- Deterministic: the same input always gives the same digest.
- One-way: you cannot recover the input from the digest.
- Collision resistant: finding two inputs with the same digest is not feasible.
Change a single bit of the input and the whole digest changes. That is why hashes work as checksums: publish the hash of a file, and anyone can verify their download.
sha256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
Anyone can compute this, because no secret is involved. That makes it good for public verification and useless for proving who sent something.
HMAC adds a secret key to the hash
HMAC stands for hash-based message authentication code. It mixes a secret key into the message and runs the hash function twice, in a construction defined in RFC 2104:
HMAC(key, msg) = H((key XOR opad) || H((key XOR ipad) || msg))
You will not need to implement this yourself, because every language ships it. What matters is the guarantee. Only someone with the key can produce a valid HMAC for a message, and only someone with the key can verify one. An attacker who intercepts a message and its HMAC cannot forge an HMAC for a different message.
hmac_sha256(key="secret", "hello") = 88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b
Hashing the secret with the message is not a safe shortcut
Never build a MAC as sha256(secret + message); use HMAC. SHA-256, specified in NIST FIPS 180-4, is built on the Merkle-Damgård construction, which is open to a length-extension attack. Given sha256(secret + message) and the length of the secret, an attacker can compute sha256(secret + message + padding + extra) without learning the secret. HMAC’s nested structure closes that hole. SHA-3 and BLAKE2 resist length extension. HMAC is still the standard interface whatever hash sits underneath, so use it anyway.
When to use SHA-256 and when to use HMAC-SHA256
| Need | Use | Why |
|---|---|---|
| Verify a download or a file has not changed | SHA-256 | No secret needed; anyone should be able to check |
| Content-addressed storage, cache keys, dedupe | SHA-256 | Only integrity and uniqueness matter |
| Git commit IDs, Docker image digests | SHA-256 (or SHA-1) | Public identifiers |
| Webhook signatures (Stripe, GitHub, Shopify) | HMAC-SHA256 | Prove the request came from the provider |
| API request signing (AWS SigV4) | HMAC-SHA256 | Prove the caller holds the key |
| JWT with HS256 | HMAC-SHA256 | The signature is an HMAC over header and payload |
| Signed cookies, tamper-proof tokens | HMAC-SHA256 | Detect client-side modification |
| Storing passwords | Neither | See below |
Hash when the question is “did this change?”, and use an HMAC when it is “did this come from a sender who holds the key?”
Neither SHA-256 nor HMAC is for passwords
Both are fast, which is an advantage everywhere except password storage. A GPU computes billions of SHA-256 digests per second, so an attacker can brute-force a leaked table of hashed passwords quickly. Salting does not fix the speed problem. An HMAC with a server-side key (a “pepper”) helps only until the key leaks along with the database.
Store passwords with a deliberately slow, memory-hard function: Argon2id, bcrypt or scrypt. PHP’s password_hash(), Python’s argon2-cffi and Node’s bcrypt package handle this correctly. Use a plain hash only when the input is high-entropy to begin with, such as an API key you generated yourself.
Computing SHA-256 and HMAC-SHA256 in JavaScript
Web Crypto (browsers and Node 19+) is async and byte-based
The Web Crypto API is asynchronous and works on bytes, so text goes through TextEncoder first. That makes it longer than you might expect for a one-line operation.
async function sha256Hex(message) {
const data = new TextEncoder().encode(message);
const digest = await crypto.subtle.digest('SHA-256', data);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
async function hmacSha256Hex(secret, message) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message));
return [...new Uint8Array(sig)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
console.log(await sha256Hex('hello'));
// 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
console.log(await hmacSha256Hex('secret', 'hello'));
// 88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b
crypto.subtle is only available in secure contexts (HTTPS or localhost).
Node.js crypto module is synchronous and shorter
The node:crypto module is synchronous and shorter, and most server code uses it.
import { createHash, createHmac } from 'node:crypto';
createHash('sha256').update('hello').digest('hex');
// 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
createHmac('sha256', 'secret').update('hello').digest('hex');
// 88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b
createHmac('sha256', 'secret').update('hello').digest('base64');
// iKqz7ejTrflNJquQ07r9SiCDBww7zOnAFO4EpEOEfAs=
Computing SHA-256 and HMAC-SHA256 in PHP
echo hash('sha256', 'hello');
// 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
echo hash_hmac('sha256', 'hello', 'secret');
// 88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b
// Raw bytes, then Base64
echo base64_encode(hash_hmac('sha256', 'hello', 'secret', true));
// iKqz7ejTrflNJquQ07r9SiCDBww7zOnAFO4EpEOEfAs=
Note the argument order: hash_hmac($algo, $data, $key), with the key last. If signing code ported from Node to PHP produces a wrong digest with no visible cause, check this first.
Computing SHA-256 and HMAC-SHA256 in Python
hashlib and hmac work on bytes, so encode strings first.
import hashlib, hmac, base64
hashlib.sha256(b'hello').hexdigest()
# '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'
hmac.new(b'secret', b'hello', hashlib.sha256).hexdigest()
# '88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b'
base64.b64encode(hmac.new(b'secret', b'hello', hashlib.sha256).digest()).decode()
# 'iKqz7ejTrflNJquQ07r9SiCDBww7zOnAFO4EpEOEfAs='
In Python the key comes first: hmac.new(key, msg, digestmod). Each of the three languages orders these arguments differently.
Hex and Base64 output encode the same bytes
The digest is 32 bytes. Hex writes each byte as two characters (64 chars). Base64 writes three bytes as four characters (44 chars, padding included). They encode the same bytes, and neither is more secure.
Each provider picks one format, and you have to match it. GitHub, Stripe, Slack and AWS SigV4 use hex. Shopify uses Base64. JWTs use Base64URL without padding. Compare in the same encoding, or compare the raw bytes.
Use a timing-safe comparison for every signature
Comparing a signature with === or == returns as soon as it finds the first differing byte. Over many requests, that timing difference leaks how many leading bytes an attacker has right. Use your language’s constant-time comparison for every signature check, including low-traffic endpoints.
// Node.js
import { timingSafeEqual } from 'node:crypto';
function safeEqual(a, b) {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
return ba.length === bb.length && timingSafeEqual(ba, bb);
}
timingSafeEqual throws if the lengths differ, so check length first.
// PHP
hash_equals($expected, $received); // true or false
# Python
hmac.compare_digest(expected, received)
Put the value you computed on the left and the untrusted value on the right. hash_equals and compare_digest handle mismatched lengths themselves.
Verifying a webhook signature with HMAC-SHA256
GitHub signs the raw body with hex HMAC
GitHub sends an X-Hub-Signature-256 header containing sha256= followed by a hex HMAC of the raw request body, keyed with the webhook secret you configured.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyGitHub(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && timingSafeEqual(a, b);
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $header)) {
http_response_code(401);
exit;
}
The body must be the exact bytes GitHub sent. If a framework has already parsed and re-serialized the JSON, the whitespace and key order may differ and the HMAC will not match. This is the usual reason a webhook verifier rejects valid requests. Verify against the raw request body, never a re-serialized one.
Stripe adds a timestamp to stop replays
Stripe’s Stripe-Signature header looks like t=1767225600,v1=<hex>. The signed payload is the timestamp, a dot, and the raw body. The timestamp lets you reject old requests that an attacker replays.
import hmac, hashlib, time
def verify_stripe(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split('=', 1) for p in header.split(','))
signed = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts['v1']):
return False
return abs(time.time() - int(parts['t'])) <= tolerance
Stripe’s official SDKs do this for you, and the code above shows what they do internally. To debug a mismatch, paste the exact signed payload and secret into the HMAC Generator and compare the hex.
A JWT with HS256 is an HMAC over header and payload
A JWT signed with HS256 uses the same idea: HMAC-SHA256(secret, base64url(header) + "." + base64url(payload)), encoded as Base64URL. The JWT Decoder shows the three parts and verifies the signature if you paste the secret. The exp and iat claims are Unix timestamps. See Unix Timestamp to Date if you need to read them by hand.
In summary, use SHA-256 when you only need to know whether something changed, and HMAC-SHA256 when you also need to know who sent it. Use Argon2id or bcrypt for passwords, a constant-time comparison for every signature, and the raw body for every webhook check.