HMAC: Hash-Based Message Authentication

HMAC (Hash-based Message Authentication Code) is a specific construction for creating a message authentication code using a cryptographic hash function combined with a secret key. Unlike plain...

Key Insights

  • HMAC combines a cryptographic hash function with a secret key to provide both data integrity and authentication, solving the length-extension vulnerability that plagues plain hashing approaches.
  • Timing attacks on HMAC verification are a real threat—always use constant-time comparison functions like hmac.compare_digest() or crypto.timingSafeEqual() to prevent attackers from guessing valid signatures byte-by-byte.
  • A secure HMAC implementation requires more than just calling a library function: you need proper key management, timestamp-based replay protection, and careful attention to which hash algorithm you choose.

What is HMAC and Why It Matters

HMAC (Hash-based Message Authentication Code) is a specific construction for creating a message authentication code using a cryptographic hash function combined with a secret key. Unlike plain hashing, which only provides integrity verification (detecting if data changed), HMAC provides authentication—proof that the message came from someone who possesses the secret key.

This distinction matters enormously in practice. If you hash a message with SHA-256 and send both the message and hash, anyone can modify the message and compute a new valid hash. With HMAC, only parties who know the secret key can produce a valid authentication tag.

You encounter HMAC constantly in modern systems: JWT tokens use HMAC-SHA256 for the HS256 algorithm, Stripe and GitHub sign webhook payloads with HMAC, AWS uses HMAC-based signatures for API request authentication, and secure session cookies rely on HMAC to prevent tampering.

How HMAC Works Under the Hood

The HMAC algorithm isn’t simply “hash the key concatenated with the message.” That naive approach is vulnerable to length-extension attacks, where attackers can append data to a message and compute a valid hash without knowing the original input.

HMAC uses a two-pass structure with inner and outer hashing operations:

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

Here’s what happens step by step:

  1. If the key is longer than the hash block size, hash it first. If shorter, pad with zeros to reach the block size. This gives you K'.
  2. XOR K’ with the inner padding (ipad), a block of 0x36 bytes.
  3. Concatenate this with the message and hash the result (inner hash).
  4. XOR K’ with the outer padding (opad), a block of 0x5c bytes.
  5. Concatenate this with the inner hash result and hash again (outer hash).

This construction prevents length-extension attacks because the outer hash operation “seals” the inner hash. An attacker cannot extend the message without knowing the key used in both passes.

import hashlib

def hmac_sha256_manual(key: bytes, message: bytes) -> bytes:
    """Manual HMAC-SHA256 implementation for educational purposes."""
    block_size = 64  # SHA-256 block size in bytes
    
    # Step 1: Normalize key length
    if len(key) > block_size:
        key = hashlib.sha256(key).digest()
    if len(key) < block_size:
        key = key + b'\x00' * (block_size - len(key))
    
    # Step 2: Create padded keys
    ipad = bytes(b ^ 0x36 for b in key)
    opad = bytes(b ^ 0x5c for b in key)
    
    # Step 3: Inner hash
    inner_hash = hashlib.sha256(ipad + message).digest()
    
    # Step 4: Outer hash
    outer_hash = hashlib.sha256(opad + inner_hash).digest()
    
    return outer_hash

# Verify against standard library
import hmac
key = b'secret-key'
message = b'authenticate this message'

manual_result = hmac_sha256_manual(key, message)
library_result = hmac.new(key, message, hashlib.sha256).digest()

assert manual_result == library_result
print(f"HMAC-SHA256: {manual_result.hex()}")

HMAC vs. Other Authentication Methods

The most common mistake is concatenating a secret with the message before hashing: hash(secret + message). This seems reasonable but falls apart with hash functions like SHA-256 that are vulnerable to length-extension attacks. An attacker who sees hash(secret + message) can compute hash(secret + message + attacker_data) without knowing the secret.

HMAC versus digital signatures is a symmetric-versus-asymmetric tradeoff. HMAC requires both parties to share the same secret key, making it unsuitable when you need non-repudiation or when the verifier shouldn’t be able to create signatures. Digital signatures (RSA, ECDSA) use key pairs—only the private key holder can sign, but anyone with the public key can verify.

Use HMAC when both parties are trusted and share a secret, such as between your backend services or for webhook verification where you control both ends. Use digital signatures when you need third-party verification or non-repudiation.

CMAC (Cipher-based MAC) and Poly1305 are alternatives worth knowing. CMAC uses block ciphers like AES instead of hash functions. Poly1305 is a high-speed authenticator typically paired with ChaCha20 for authenticated encryption. For most application-level authentication needs, HMAC-SHA256 remains the pragmatic choice due to widespread library support and well-understood security properties.

Implementing HMAC Securely

Choose SHA-256 or SHA-3 for new implementations. SHA-1 has known weaknesses and MD5 is completely broken for security purposes. While HMAC-MD5 isn’t as catastrophically broken as plain MD5 (the HMAC construction provides some protection), there’s no reason to use it in new code.

Key generation matters more than most developers realize. Your HMAC key should be generated using a cryptographically secure random number generator, not derived from passwords or predictable values.

import hmac
import hashlib
import secrets

# Generate a secure random key (32 bytes = 256 bits)
secret_key = secrets.token_bytes(32)

def create_hmac(key: bytes, message: bytes) -> str:
    """Create HMAC-SHA256 signature."""
    signature = hmac.new(key, message, hashlib.sha256)
    return signature.hexdigest()

message = b'user_id=12345&action=transfer&amount=1000'
signature = create_hmac(secret_key, message)
print(f"Message: {message.decode()}")
print(f"Signature: {signature}")
const crypto = require('crypto');

// Generate a secure random key
const secretKey = crypto.randomBytes(32);

function createHmac(key, message) {
    return crypto.createHmac('sha256', key)
        .update(message)
        .digest('hex');
}

const message = 'user_id=12345&action=transfer&amount=1000';
const signature = createHmac(secretKey, message);
console.log(`Message: ${message}`);
console.log(`Signature: ${signature}`);

Store keys securely using environment variables, secret management services (AWS Secrets Manager, HashiCorp Vault), or hardware security modules for high-security applications. Never commit keys to version control.

Verifying HMACs: Avoiding Timing Attacks

Naive string comparison leaks information through timing. When comparing two strings character by character, the comparison returns false as soon as a mismatch is found. An attacker can measure response times to determine how many characters of their forged signature match the real one, eventually guessing the entire signature.

# DANGEROUS: Timing attack vulnerable
def insecure_verify(expected: str, received: str) -> bool:
    return expected == received  # Returns early on first mismatch

# SECURE: Constant-time comparison
import hmac

def secure_verify(key: bytes, message: bytes, received_signature: str) -> bool:
    expected_signature = hmac.new(key, message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected_signature, received_signature)
const crypto = require('crypto');

// DANGEROUS: Timing attack vulnerable
function insecureVerify(expected, received) {
    return expected === received;
}

// SECURE: Constant-time comparison
function secureVerify(key, message, receivedSignature) {
    const expectedSignature = crypto.createHmac('sha256', key)
        .update(message)
        .digest('hex');
    
    const expectedBuffer = Buffer.from(expectedSignature, 'hex');
    const receivedBuffer = Buffer.from(receivedSignature, 'hex');
    
    if (expectedBuffer.length !== receivedBuffer.length) {
        return false;
    }
    
    return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}

The constant-time functions compare all bytes regardless of where mismatches occur, taking the same amount of time whether zero bytes match or all bytes match.

Practical Applications

Webhook signature verification is one of the most common HMAC use cases. Here’s a pattern based on how Stripe and GitHub handle it:

import hmac
import hashlib
import time
import json

class SignedRequestHandler:
    def __init__(self, secret_key: bytes, max_age_seconds: int = 300):
        self.secret_key = secret_key
        self.max_age_seconds = max_age_seconds
    
    def sign_request(self, payload: dict) -> tuple[str, str, str]:
        """Sign a request payload with timestamp."""
        timestamp = str(int(time.time()))
        body = json.dumps(payload, separators=(',', ':'), sort_keys=True)
        
        # Include timestamp in signed data to prevent replay attacks
        signed_payload = f"{timestamp}.{body}"
        signature = hmac.new(
            self.secret_key,
            signed_payload.encode(),
            hashlib.sha256
        ).hexdigest()
        
        return body, timestamp, signature
    
    def verify_request(self, body: str, timestamp: str, signature: str) -> bool:
        """Verify a signed request."""
        # Check timestamp to prevent replay attacks
        try:
            request_time = int(timestamp)
        except ValueError:
            return False
        
        current_time = int(time.time())
        if abs(current_time - request_time) > self.max_age_seconds:
            return False  # Request too old or from the future
        
        # Verify signature
        signed_payload = f"{timestamp}.{body}"
        expected_signature = hmac.new(
            self.secret_key,
            signed_payload.encode(),
            hashlib.sha256
        ).hexdigest()
        
        return hmac.compare_digest(expected_signature, signature)

# Usage
handler = SignedRequestHandler(secrets.token_bytes(32))

# Client side: sign the request
payload = {"user_id": 123, "action": "update_email", "email": "new@example.com"}
body, timestamp, signature = handler.sign_request(payload)

# Server side: verify the request
# Headers would contain: X-Timestamp: {timestamp}, X-Signature: {signature}
is_valid = handler.verify_request(body, timestamp, signature)
print(f"Request valid: {is_valid}")

The timestamp prevents replay attacks—an attacker who captures a valid signed request cannot replay it indefinitely because the timestamp will eventually fall outside the acceptable window.

Common Pitfalls and Security Checklist

Hardcoded or weak keys are the most common vulnerability. I’ve seen production systems using keys like "secret" or API keys that were meant to be rotated but never were. Generate keys with secrets.token_bytes(32) or equivalent.

Using deprecated hash functions still happens. MD5 and SHA-1 should not appear in new code. Some legacy systems require them for compatibility, but document this technical debt prominently.

Missing or improper verification occurs when developers implement signing but forget to actually verify signatures on the receiving end, or verify but don’t reject invalid signatures.

Security Checklist:

  • Key is at least 256 bits, generated with a CSPRNG
  • Key is stored securely (environment variable, secrets manager)
  • Using SHA-256 or SHA-3, not MD5 or SHA-1
  • Verification uses constant-time comparison
  • Timestamp included to prevent replay attacks
  • Timestamp tolerance is reasonable (5 minutes or less)
  • Failed verification returns generic error (no information leakage)
  • Key rotation process is documented and tested

HMAC is a foundational building block for secure systems. Get it right, and you have a reliable, fast authentication mechanism. Get it wrong, and you’ve built a false sense of security that attackers will eventually exploit.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.