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:

  • H be the underlying hash function (e.g., SHA-256)
  • K be the secret key
  • m be the message
  • B be the block size of H in bytes (64 bytes for SHA-256, 128 bytes for SHA-512)
  • ipad = the byte 0x36 repeated B times
  • opad = the byte 0x5C repeated B times

Key preparation (K'):

  • If len(K) > B: K' = H(K) (hash the key down to hash output size, then pad with zeros to length B)
  • If len(K) < B: K' = K padded with zero bytes to length B
  • If len(K) == B: K' = K

The formula:

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

In plain English:

  1. XOR the padded key with ipad (inner pad: 0x36 repeated)
  2. Prepend the result to the message
  3. Hash that combined input - this is the inner hash
  4. XOR the padded key with opad (outer pad: 0x5C repeated)
  5. Prepend the result to the inner hash
  6. 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_equals in PHP, hmac.compare_digest in Python)
  • Include a timestamp in signed payloads and validate it to prevent replay attacks (as Stripe does)

More Articles

CSV vs JSON for Data Exchange: When Each Format Wins

A practical comparison of CSV and JSON for APIs, data pipelines, and file exports. Covers structure, parsing, streaming, schema enforcement, size, tooling, and clear guidelines for choosing the right format.

15 April, 2026

SEO for AI Search: How to Optimise for ChatGPT, Perplexity, and Google AI Overviews

How AI-powered search engines discover, evaluate, and cite web content. Practical strategies for optimising your pages for ChatGPT Browse, Perplexity, Google AI Overviews, and other AI answer engines.

14 April, 2026

Image to Base64 Data URIs: When to Inline and When Not To

A practical guide to embedding images as Base64 data URIs. Covers the data URI format, size overhead, performance trade-offs, browser caching, Content Security Policy, and clear rules for when inlining helps vs hurts.

10 April, 2026