HMAC Explained: Data Signing, JWT, and Protection Against Tampering
12 March, 2026 Updated: 2 March, 2026 Security
What Is HMAC
HMAC stands for Hash-based Message Authentication Code. It is defined in RFC 2104 (published 1997) and provides a way to verify both the integrity and the authenticity of a message using a shared secret key and a cryptographic hash function.
Two distinct problems need solving when transmitting data between parties:
- Integrity - has the message been altered in transit?
- Authenticity - did the message come from someone who knows the shared secret?
A plain cryptographic hash (SHA-256, for example) only addresses integrity when you can compare it to a trusted reference. It does not address authenticity, because anyone can compute a hash. HMAC solves both problems by involving a secret key in the computation. Without the key, you cannot produce a valid HMAC for a given message, and you cannot verify one. You can experiment with different keys and algorithms using the HMAC generator.
Crucially, HMAC achieves this without requiring public key infrastructure (PKI), certificates, or asymmetric cryptography. Both parties share a secret key, and the security of the scheme depends entirely on keeping that key secret.
How HMAC Works
The HMAC construction is defined precisely. Let:
Hbe the underlying hash function (e.g., SHA-256)Kbe the secret keymbe the messageBbe the block size ofHin bytes (64 bytes for SHA-256, 128 bytes for SHA-512)ipad= the byte0x36repeatedBtimesopad= the byte0x5CrepeatedBtimes
Key preparation (K'):
- If
len(K) > B:K' = H(K)(hash the key down to hash output size, then pad with zeros to lengthB) - If
len(K) < B:K' = Kpadded with zero bytes to lengthB - If
len(K) == B:K' = K
The formula:
HMAC(K, m) = H( (K' XOR opad) || H( (K' XOR ipad) || m ) )
In plain English:
- XOR the padded key with
ipad(inner pad:0x36repeated) - Prepend the result to the message
- Hash that combined input - this is the inner hash
- XOR the padded key with
opad(outer pad:0x5Crepeated) - Prepend the result to the inner hash
- Hash that combined input - this is the HMAC output
Why Two Passes?
The two-pass construction is not arbitrary. It is the defense against a specific attack called the length extension attack, which is explained in detail in the next section.
The outer pass (step 4-6) wraps the inner hash in a second keyed operation. Even if an attacker can manipulate the inner hash computation, they cannot produce a valid outer hash without knowing the key. The two different pad values (ipad and opad) ensure the inner and outer key derivations are independent.
Worked Example with HMAC-SHA256
For H = SHA-256, B = 64 bytes:
K' = secret key padded or hashed to 64 bytes
ipad = 0x3636363636...36 (64 bytes)
opad = 0x5C5C5C5C5C...5C (64 bytes)
inner_key = K' XOR ipad
inner_hash = SHA-256(inner_key || message)
outer_key = K' XOR opad
HMAC = SHA-256(outer_key || inner_hash)
The output is 32 bytes (256 bits) for HMAC-SHA256.
HMAC vs Simple Hash
A common mistake is to think that hash(key + message) achieves the same result as HMAC. It does not, and the vulnerability is concrete.
The Length Extension Attack
SHA-256, SHA-512, and other hash functions based on the Merkle-Damgard construction are vulnerable to length extension attacks. This is a fundamental property of the construction, not a bug.
Here is how it works. SHA-256 processes input in 64-byte blocks. After hashing a message, the internal state of the hash function is the hash output itself. If you know H(prefix || message), you can continue the hash computation to produce H(prefix || message || padding || extension) - without knowing prefix.
Applied to authentication: if you use tag = SHA-256(secret_key || message) and an attacker knows tag and message, they can compute SHA-256(secret_key || message || padding || malicious_extension) without knowing secret_key. They can forge authentication tags for extended messages.
| Property | Plain hash H(key || msg) |
HMAC |
|---|---|---|
| Proves message integrity | Yes | Yes |
| Proves message authenticity | Partially | Yes |
| Requires a key | Yes (but used incorrectly) | Yes |
| Vulnerable to length extension | Yes (SHA-1, SHA-2) | No |
| Secure construction | No | Yes |
HMAC's inner hash processes (K' XOR ipad) || message - the key material is mixed in at the block level, not just prepended. The outer hash then wraps the result with a differently-derived key, breaking the linear structure that length extension exploits.
SHA-3 (Keccak) is not vulnerable to length extension due to its sponge construction. However, HMAC-SHA3 is still well-defined and correct; it simply does not gain the specific anti-length-extension benefit over plain keyed SHA-3 that HMAC-SHA2 does over plain keyed SHA-2.
HMAC Algorithms
HMAC is parameterized by the underlying hash function. The choice of hash affects output size and security margin.
HMAC-SHA256
- Output size: 32 bytes (256 bits)
- Security level: 128-bit security (collision resistance)
- Use case: The most widely deployed choice. Used in JWT (HS256), AWS Signature Version 4, TLS 1.2/1.3, Stripe webhooks, GitHub webhooks
- Recommendation: Default choice for new systems
HMAC-SHA512
- Output size: 64 bytes (512 bits)
- Security level: 256-bit security
- Use case: Higher security margin, slightly more expensive to compute on 32-bit platforms, faster than SHA-256 on 64-bit hardware (wider registers)
- Recommendation: Use when you want extra security margin or are targeting 64-bit server environments
HMAC-SHA384
- Output size: 48 bytes (384 bits)
- Security level: 192-bit security
- Use case: Matches NIST P-384 elliptic curve security level; used in JWT (HS384)
- Recommendation: Niche use - prefer SHA-256 or SHA-512
HMAC-SHA1
- Output size: 20 bytes (160 bits)
- Security level: Below current recommendations (SHA-1 has known collision vulnerabilities)
- Use case: Legacy systems, older protocols (e.g., old OAuth 1.0 implementations)
- Recommendation: Avoid for new systems. SHA-1 collisions are practical; while HMAC-SHA1 is not directly broken in the same way, migrating off SHA-1 entirely is the correct long-term approach
Algorithm Selection
For new systems, use HMAC-SHA256 unless you have a specific reason to use a different variant. It has an excellent security margin, is universally supported, and its 32-byte output is manageable in headers, tokens, and storage.
JWT and HMAC
JSON Web Tokens (JWT) use HMAC when a shared secret is sufficient. The JWT specification (RFC 7518) defines three symmetric HMAC algorithms:
| JWT Algorithm | HMAC Variant | Output |
|---|---|---|
| HS256 | HMAC-SHA256 | 32 bytes |
| HS384 | HMAC-SHA384 | 48 bytes |
| HS512 | HMAC-SHA512 | 64 bytes |
How JWT Signing Works with HMAC
A JWT consists of three parts: header.payload.signature, all base64url-encoded.
signature = HMAC-SHA256(
secret_key,
base64url(header) + "." + base64url(payload)
)
The signature is then base64url-encoded and appended. The recipient recomputes the HMAC over the same header and payload using the shared secret and compares it to the signature in the token. You can inspect this process step by step with the JWT encoder/decoder.
HS256 vs RS256 - When to Use Which
The fundamental difference is key architecture:
HS256 (symmetric - shared secret):
- Both the issuer and the verifier use the same secret key
- If the verifier is compromised, the secret is exposed and attackers can forge tokens
- Simpler to implement - no certificate infrastructure
- Use when: You control both the token issuer and all token verifiers (single service, or microservices you operate), and you can securely share the secret
RS256 (asymmetric - RSA key pair):
- The issuer signs with a private key; verifiers check with the public key
- Verifiers never see the private key - a compromised verifier cannot forge tokens
- Enables publishing the public key (JWKS endpoint) for third-party verification
- Use when: Third parties need to verify your tokens, you have multiple independent consumers, or you are building an OAuth 2.0 / OIDC identity provider
ES256 (asymmetric - ECDSA with P-256):
- Same trust model as RS256 but with smaller key and signature sizes
- Use when: Same as RS256, but where token size is a concern (e.g., in cookies or URL parameters)
For internal service-to-service authentication within a trusted infrastructure you control, HS256 is entirely appropriate. For any public-facing or multi-tenant token scenario, prefer RS256 or ES256.
API Authentication with HMAC
HMAC is the backbone of several widely-used API authentication schemes.
AWS Signature Version 4
AWS uses a sophisticated HMAC-SHA256 scheme where the signing key is derived from the secret access key through a chain of HMAC operations. This is called key derivation - a different signing key is used for each combination of date, region, and service.
# Pseudocode for AWS SigV4 signing key derivation
kSecret = "AWS4" + aws_secret_access_key
kDate = HMAC-SHA256(kSecret, date_string) # e.g., "20240413"
kRegion = HMAC-SHA256(kDate, region_name) # e.g., "us-east-1"
kService = HMAC-SHA256(kRegion, service_name) # e.g., "s3"
kSigning = HMAC-SHA256(kService, "aws4_request")
# Final signature
string_to_sign = "AWS4-HMAC-SHA256\n" + datetime + "\n" + credential_scope + "\n" + hashed_canonical_request
signature = HMAC-SHA256(kSigning, string_to_sign)
The key derivation chain means that a signing key is scoped to a specific date, region, and service. Even if a derived signing key is compromised, it cannot sign requests for a different service or region, and it expires after one day.
Stripe Webhook Verification
Stripe signs webhook payloads using HMAC-SHA256. The signature is sent in the Stripe-Signature header:
Stripe-Signature: t=1712972786,v1=abc123...,v1=def456...
The signed payload is timestamp + "." + raw_request_body. Stripe includes the timestamp to prevent replay attacks.
<?php
declare(strict_types=1);
function verifyStripeWebhook(
string $rawPayload,
string $signatureHeader,
string $webhookSecret,
int $toleranceSeconds = 300
): bool {
// Parse the Stripe-Signature header
$parts = [];
foreach (explode(',', $signatureHeader) as $part) {
[$key, $value] = explode('=', $part, 2);
$parts[$key][] = $value;
}
if (empty($parts['t']) || empty($parts['v1'])) {
return false;
}
$timestamp = (int) $parts['t'][0];
// Reject events older than tolerance window (replay attack prevention)
if (abs(time() - $timestamp) > $toleranceSeconds) {
return false;
}
// Compute expected signature
$signedPayload = $timestamp . '.' . $rawPayload;
$expectedSignature = hash_hmac('sha256', $signedPayload, $webhookSecret);
// Check against all v1 signatures in the header (Stripe rotates keys)
foreach ($parts['v1'] as $receivedSignature) {
if (hash_equals($expectedSignature, $receivedSignature)) {
return true;
}
}
return false;
}
GitHub Webhook Verification
GitHub sends the X-Hub-Signature-256 header containing sha256=<HMAC-SHA256 of raw payload>:
<?php
declare(strict_types=1);
function verifyGitHubWebhook(string $rawPayload, string $signatureHeader, string $secret): bool
{
if (!str_starts_with($signatureHeader, 'sha256=')) {
return false;
}
$receivedSignature = substr($signatureHeader, 7);
$expectedSignature = hash_hmac('sha256', $rawPayload, $secret);
return hash_equals($expectedSignature, $receivedSignature);
}
Timing Attacks and Constant-Time Comparison
This is one of the most important practical details when working with HMAC verification, and it is frequently missed.
The Vulnerability
Standard string comparison in most languages (==, ===, strcmp) is not constant-time. The comparison typically stops at the first byte that differs:
"abc" == "abd" → compares 'a'=='a', 'b'=='b', 'c'=='d' → false (3 comparisons)
"abc" == "xyz" → compares 'a'=='x' → false (1 comparison)
The time taken by the comparison leaks information about how many bytes matched. An attacker who can make many requests and measure response times can exploit this to guess an HMAC one byte at a time. This is a timing side-channel attack.
For a 32-byte HMAC-SHA256, instead of needing to try 256^32 combinations, an attacker reduces the problem to 32 sequential searches of 256 values each - still difficult but fundamentally different in complexity.
The Fix: Constant-Time Comparison
Always use a constant-time comparison function when verifying HMACs, signatures, or any secret value:
PHP (hash_equals - available since PHP 5.6):
// WRONG - vulnerable to timing attack
if ($computedHmac === $receivedHmac) { ... }
// CORRECT - constant-time comparison
if (hash_equals($computedHmac, $receivedHmac)) { ... }
hash_equals() in PHP compares two strings in constant time. It always runs for the length of the longer string regardless of where differences occur.
Python (hmac.compare_digest - available since Python 3.3):
import hmac
# WRONG
if computed_hmac == received_hmac:
...
# CORRECT
if hmac.compare_digest(computed_hmac, received_hmac):
...
JavaScript (Web Crypto API uses constant-time internally):
The SubtleCrypto API's verify() method performs constant-time comparison internally, so you do not need to implement it yourself when using the built-in API.
Code Examples
PHP
<?php
declare(strict_types=1);
// Basic HMAC-SHA256
$secret = 'my-secret-key';
$message = 'Hello, World!';
$hmac = hash_hmac('sha256', $message, $secret);
echo $hmac; // 64-character hex string (32 bytes)
// With raw binary output (for storage efficiency)
$hmacBinary = hash_hmac('sha256', $message, $secret, raw_output: true);
echo base64_encode($hmacBinary); // base64 of 32 bytes
// Verification (ALWAYS use hash_equals)
function verifyHmac(string $message, string $receivedHmac, string $secret): bool
{
$expectedHmac = hash_hmac('sha256', $message, $secret);
return hash_equals($expectedHmac, $receivedHmac);
}
// HMAC-SHA512
$hmac512 = hash_hmac('sha512', $message, $secret);
echo strlen($hmac512); // 128 hex chars (64 bytes)
// Available algorithms
$algos = hash_hmac_algos();
// Includes: sha256, sha512, sha384, sha1, md5, etc.
// API request signing
function signApiRequest(string $method, string $path, string $body, string $apiSecret): string
{
$timestamp = time();
$nonce = bin2hex(random_bytes(16));
$payload = implode("\n", [$method, $path, $timestamp, $nonce, hash('sha256', $body)]);
return implode(':', [
$timestamp,
$nonce,
hash_hmac('sha256', $payload, $apiSecret),
]);
}
Python
import hmac
import hashlib
import base64
import time
import secrets
# Basic HMAC-SHA256
secret = b'my-secret-key'
message = b'Hello, World!'
mac = hmac.new(secret, message, hashlib.sha256)
print(mac.hexdigest()) # 64-character hex string
print(mac.digest()) # 32 raw bytes
# Alternative using hashlib directly
mac2 = hmac.new(secret, message, 'sha256')
print(mac2.hexdigest())
# Verification (ALWAYS use compare_digest)
def verify_hmac(message: bytes, received_hmac: str, secret: bytes) -> bool:
expected = hmac.new(secret, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received_hmac)
# HMAC-SHA512
mac512 = hmac.new(secret, message, hashlib.sha512)
print(len(mac512.digest())) # 64 bytes
# API request signing
def sign_api_request(method: str, path: str, body: bytes, api_secret: bytes) -> str:
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)
body_hash = hashlib.sha256(body).hexdigest()
payload = '\n'.join([method, path, timestamp, nonce, body_hash]).encode()
signature = hmac.new(api_secret, payload, hashlib.sha256).hexdigest()
return f"{timestamp}:{nonce}:{signature}"
JavaScript
// Using Web Crypto API (browser and Node.js 18+)
async function createHmac(secret, message) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false, // not extractable
['sign', 'verify']
);
const signature = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(message)
);
// Convert ArrayBuffer to hex string
return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
async function verifyHmac(secret, message, receivedHmacHex) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify']
);
// Convert hex string back to ArrayBuffer
const receivedBytes = new Uint8Array(
receivedHmacHex.match(/.{2}/g).map(byte => parseInt(byte, 16))
);
// verify() performs constant-time comparison internally
return crypto.subtle.verify('HMAC', key, receivedBytes, encoder.encode(message));
}
// Usage
const hmac = await createHmac('my-secret-key', 'Hello, World!');
console.log(hmac); // 64-character hex string
const isValid = await verifyHmac('my-secret-key', 'Hello, World!', hmac);
console.log(isValid); // true
// Node.js crypto module (alternative for Node.js environments)
const crypto = require('crypto');
const hmacNode = crypto.createHmac('sha256', 'my-secret-key')
.update('Hello, World!')
.digest('hex');
console.log(hmacNode);
// Constant-time comparison in Node.js
const isValidNode = crypto.timingSafeEqual(
Buffer.from(hmacNode, 'hex'),
Buffer.from(receivedHmac, 'hex')
);
What to Remember
HMAC is the correct primitive to reach for when you need to verify both message integrity and authenticity using a shared secret. It is ubiquitous in web security for good reason: the two-pass construction is provably secure (assuming the underlying hash function is secure), it resists length extension attacks that break naive hash(key + message) constructions, and it is fast.
Key points to remember:
- HMAC proves both integrity and authenticity - a plain hash proves neither without a key
hash(key || message)is broken for SHA-1 and SHA-2 due to length extension - always use HMAC- HMAC-SHA256 is the right default for new systems
- HS256 (JWT) is appropriate for single-service token signing; use RS256/ES256 when third parties need to verify tokens
- Always compare HMACs with constant-time functions (
hash_equalsin PHP,hmac.compare_digestin Python) - Include a timestamp in signed payloads and validate it to prevent replay attacks (as Stripe does)