HMAC isn’t just about proving who sent a message; it’s about proving that the message hasn’t changed since it was sent, even if you don’t know the sender.

Let’s see it in action. Imagine two services, ServiceA and ServiceB, that need to exchange a sensitive piece of data, say a user ID and their role.

import hmac
import hashlib
import json

# Shared secret key (must be kept confidential!)
SECRET_KEY = b'my-super-secret-key-that-no-one-else-knows'

def create_message(user_id: str, role: str) -> dict:
    """Creates a message and signs it with HMAC."""
    payload = {
        "user_id": user_id,
        "role": role,
        "timestamp": 1678886400 # Example timestamp
    }
    payload_bytes = json.dumps(payload, sort_keys=True).encode('utf-8')

    # Create the HMAC signature
    signature = hmac.new(SECRET_KEY, payload_bytes, hashlib.sha256).hexdigest()

    return {
        "payload": payload,
        "signature": signature
    }

def verify_message(message: dict) -> bool:
    """Verifies the HMAC signature of a received message."""
    payload_bytes = json.dumps(message["payload"], sort_keys=True).encode('utf-8')
    expected_signature = hmac.new(SECRET_KEY, payload_bytes, hashlib.sha256).hexdigest()

    # Use hmac.compare_digest for constant-time comparison to prevent timing attacks
    return hmac.compare_digest(message["signature"], expected_signature)

# Service A sends a message
original_message = create_message("user123", "admin")
print("Original Message:")
print(json.dumps(original_message, indent=2))

# Service B receives the message and verifies it
is_valid = verify_message(original_message)
print(f"\nIs the original message valid? {is_valid}")

# --- Tampering Example ---
tampered_payload = original_message["payload"].copy()
tampered_payload["role"] = "guest" # Attacker changes the role

tampered_message = {
    "payload": tampered_payload,
    "signature": original_message["signature"] # Keep the old signature
}
print("\nTampered Message (with old signature):")
print(json.dumps(tampered_message, indent=2))

is_valid_tampered = verify_message(tampered_message)
print(f"\nIs the tampered message valid? {is_valid_tampered}")

# --- Tampering Example 2 (Attacker tries to re-sign) ---
# If an attacker doesn't know the SECRET_KEY, they can't re-sign correctly.
# Let's simulate if they tried to sign with a different key (they'd fail)
WRONG_KEY = b'a-different-secret-key'
tampered_payload_bytes = json.dumps(tampered_payload, sort_keys=True).encode('utf-8')
attacker_signed_tampered_message = {
    "payload": tampered_payload,
    "signature": hmac.new(WRONG_KEY, tampered_payload_bytes, hashlib.sha256).hexdigest()
}
print("\nTampered Message (attacker tries to sign with wrong key):")
print(json.dumps(attacker_signed_tampered_message, indent=2))

is_valid_attacker_signed = verify_message(attacker_signed_tampered_message)
print(f"\nIs the attacker-signed message valid? {is_valid_attacker_signed}")

Here’s how this works: ServiceA takes its data, serializes it deterministically (using json.dumps with sort_keys=True is crucial for consistency), and then uses a shared secret key and a cryptographic hash function (like SHA-256) to compute a unique "fingerprint" of that data. This fingerprint is the HMAC signature. It sends both the original data and the signature. ServiceB receives the data and the signature. It then performs the exact same computation: serializes the received data deterministically, and computes a new HMAC signature using the same shared secret key. If the newly computed signature matches the one ServiceA sent, ServiceB knows two things:

  1. The data hasn’t been altered in transit because any change, no matter how small, would produce a completely different signature.
  2. The message was indeed generated by something that knows the shared secret key.

The core problem HMAC solves is message integrity and authenticity in a way that doesn’t require public-key cryptography. You don’t need certificates or complex key management; just a shared secret. The entire system hinges on the secrecy of that key. If an attacker obtains the SECRET_KEY, they can forge any message and its signature.

The hmac.compare_digest function is vital. When comparing two strings (like signatures), a naive == comparison can be susceptible to timing attacks. An attacker could measure how long the comparison takes to infer parts of the correct signature. compare_digest always takes the same amount of time, regardless of where the mismatch occurs or if the strings match, making it safe for security-sensitive comparisons.

The timestamp in the payload is a common addition. While HMAC prevents tampering, it doesn’t prevent replay attacks – where an attacker intercepts a valid, signed message and resends it later. Including a timestamp (and verifying it’s recent enough on the receiving end) helps mitigate this.

The most surprising part is how little secret you actually need. You don’t need to protect the algorithm (SHA-256, HMAC) or the message content itself from the verifier (they receive it anyway). You only need to protect the shared secret key. This makes it incredibly lightweight for systems that need to quickly verify messages without the overhead of asymmetric cryptography.

The next rabbit hole is understanding how to manage and rotate that SECRET_KEY securely across multiple services without compromising its confidentiality.

Want structured learning?

Take the full Cryptography course →