Dev.to Security 🔐 Cybersecurity 👁 0 📖 13 min read

HMAC Authentication: What It Is and How It Works

Passwords and API keys get stolen. HMAC proves a message was sent by someone who holds a shared secret — without ever sending the secret itself. Here is how it works and how to implement it. Originally published on Mor

Passwords and API keys get stolen. HMAC proves a message was sent by someone who holds a shared secret — without ever sending the secret itself. Here is how it works and how to implement it.

Originally published on MoreOnlineTools Blog.

What Is HMAC?

HMAC stands for Hash-based Message Authentication Code. It is a mechanism that uses a cryptographic hash function combined with a shared secret key to verify two things simultaneously: that a message has not been tampered with (integrity), and that it was sent by someone who possesses the shared secret (authenticity).

In the simplest terms, HMAC produces a fixed-length "signature" of a message. The sender computes this signature using their secret key and attaches it to the message. The receiver independently computes the same signature using their copy of the same secret key. If the two signatures match, the message is authentic and intact. If they do not match, either the message was tampered with or the sender does not hold the correct key.

HMAC is not encryption. It does not hide the content of a message — a third party can read the payload. What HMAC proves is who sent it and that it was not changed in transit. If you need confidentiality, combine HMAC with encryption (HTTPS already does this for you in transit).

The Two Problems HMAC Solves

  • Integrity: Any change to the message — even a single bit flip — produces a completely different HMAC signature. An attacker cannot modify the payload without invalidating the signature (assuming they do not have the secret key).
  • Authenticity: Only someone who holds the shared secret can produce a valid HMAC. An attacker who intercepts the message cannot forge a valid signature for a new payload.

The HMAC Algorithm Step by Step

HMAC is defined in RFC 2104. The formal definition looks intimidating, but the logic is straightforward once you break it down.

The formula for HMAC-SHA256 (using SHA-256 as the hash function) is:

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

Where:

  • H — the hash function (SHA-256 in our case)
  • K — the secret key
  • K' — the key padded or hashed to the block size of the hash function (64 bytes for SHA-256)
  • m — the message
  • ipad — inner padding: the byte 0x36 repeated 64 times
  • opad — outer padding: the byte 0x5C repeated 64 times
  • ⊕ — XOR operation
  • || — concatenation

Step-by-Step Walkthrough

  1. Prepare the key: If the key is shorter than the block size (64 bytes for SHA-256), pad it with zeros. If it is longer, hash it first to reduce it to the block size.
  2. Inner hash: XOR the padded key with ipad, concatenate the message, and hash the result: H((K' ⊕ ipad) || m).
  3. Outer hash: XOR the padded key with opad, concatenate the inner hash result, and hash again: H((K' ⊕ opad) || inner_hash).
  4. Result: The output of the outer hash is the HMAC signature — 32 bytes (256 bits) for HMAC-SHA256.

Why Two Rounds of Hashing?

A naive approach might be to simply hash the key and message together: H(K || m). This is vulnerable to a length extension attack: an attacker who knows H(K || m) can compute H(K || m || extra_data) without knowing K, simply by appending data and continuing the hash computation.

The two-round construction (inner hash + outer hash with different XOR pads) prevents this entirely. The outer hash wraps the inner hash result, making length extension impossible. This is what makes HMAC secure where simple keyed hashes are not.

Do not invent your own MAC scheme. HMAC has been formally analyzed and proven secure. Custom constructions like H(key + message) or H(message + key) have well-documented attacks. Use HMAC as specified.

HMAC vs Other Authentication Methods

Understanding where HMAC fits compared to other authentication mechanisms helps you choose the right tool for the right job.

Method How It Works Replay Protection Stateless Best For
API Key Static token sent with every request None Yes Simple server-to-server, low-risk APIs
Basic Auth Username:password Base64-encoded in header None Yes Internal tools, legacy systems
JWT Self-contained token signed with secret or private key Optional Yes User authentication, session management
HMAC Signature Request payload signed with shared secret + timestamp Built-in Yes Webhooks, payment callbacks, inter-service APIs
OAuth 2.0 Delegated access with short-lived tokens Yes Partial Third-party access, user-facing APIs

When to Use HMAC Over JWT

JWT and HMAC are sometimes confused because both involve signing with a secret key. The key difference is what they sign:

  • JWT signs a structured payload (claims about a user or session) and is typically used for identity — proving who the caller is.
  • HMAC webhook signatures sign the raw request body and timestamp, and are used for message integrity — proving that a specific payload came from a specific sender and has not been modified.

Use HMAC when a server needs to verify that incoming HTTP requests (especially webhooks) genuinely came from a trusted party and were not replayed or tampered with. Use JWT when you need to pass user identity claims between services.

Webhook Signature Verification

The most common real-world use of HMAC is webhook signature verification. When GitHub, Stripe, Shopify, or any payment processor sends a webhook to your server, they include an HMAC signature so you can verify the request is legitimate before acting on it.

The pattern works like this:

  1. When you register a webhook endpoint, you provide (or receive) a shared secret.
  2. When the sender fires a webhook, it computes HMAC-SHA256(secret, payload) — often including a timestamp in the signed data.
  3. The sender includes the signature in a request header (e.g. X-Hub-Signature-256, Stripe-Signature).
  4. Your server receives the request, independently computes the same HMAC, and compares signatures using constant-time comparison.
  5. If signatures match: process the webhook. If not: reject it with a 401.

Why include a timestamp?

Without a timestamp in the signed data, an attacker who captures a valid request can replay it indefinitely — the signature will always be valid. Including the current timestamp (and rejecting requests older than 5 minutes) prevents replay attacks.

Timing Attacks — The Hidden Danger

This is the most important security detail that most HMAC tutorials skip. Even if your HMAC implementation is mathematically correct, a naive string comparison can leak the secret key through timing attacks.

How Timing Attacks Work

Standard string comparison (===, ==, strcmp) returns false as soon as it finds the first differing character. This means the comparison takes longer for strings that share a longer common prefix. An attacker can measure the response time of thousands of requests to statistically determine — byte by byte — what the correct HMAC signature is, without ever knowing the secret key.

This is not theoretical. Timing attacks on HMAC comparisons have been demonstrated in practice against production systems.

Never use regular string comparison for HMAC verification. Always use a constant-time comparison function that takes the same amount of time regardless of how many characters match.

Constant-Time Comparison by Language

Language Safe Function Unsafe
PHP hash_equals($known, $user) $a === $b
Python hmac.compare_digest(a, b) a == b
Node.js crypto.timingSafeEqual(a, b) a === b
Go hmac.Equal(a, b) a == b
Ruby Rack::Utils.secure_compare(a, b) a == b

Implementation Examples

The following are complete, production-ready webhook verification functions for the three most common server-side languages. Each includes HMAC computation, timestamp validation, and constant-time comparison.

PHP Implementation

<?php

/**
 * Verify an incoming webhook signature.
 *
 * @param string $payload     Raw request body (do NOT parse first)
 * @param string $signature   Signature from request header (e.g. sha256=abc123...)
 * @param string $secret      Your shared webhook secret
 * @param int    $timestamp   Unix timestamp from header (0 to skip check)
 * @param int    $tolerance   Max age in seconds (default: 300 = 5 minutes)
 * @return bool
 */
function verifyWebhookSignature(
    string $payload,
    string $signature,
    string $secret,
    int $timestamp = 0,
    int $tolerance = 300
): bool {
    // Replay attack protection
    if ($timestamp > 0) {
        $age = abs(time() - $timestamp);
        if ($age > $tolerance) {
            return false; // Request too old (or from the future)
        }
    }

    // Compute the expected HMAC
    // Include timestamp in signed data if provided
    $signed_payload = $timestamp > 0
        ? "{$timestamp}.{$payload}"
        : $payload;

    $expected = 'sha256=' . hash_hmac('sha256', $signed_payload, $secret);

    // CRITICAL: constant-time comparison to prevent timing attacks
    return hash_equals($expected, $signature);
}

// Example usage — GitHub webhook
$payload   = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$secret    = 'your_webhook_secret_here';

if (!verifyWebhookSignature($payload, $signature, $secret)) {
    http_response_code(401);
    exit('Invalid signature');
}

// Safe to process
$data = json_decode($payload, true);

Python Implementation

import hmac
import hashlib
import time
from typing import Optional


def verify_webhook_signature(
    payload: bytes,
    signature: str,
    secret: str,
    timestamp: Optional[int] = None,
    tolerance: int = 300
) -> bool:
    """
    Verify an incoming webhook signature using HMAC-SHA256.

    Args:
        payload:   Raw request body bytes (do NOT decode/parse first)
        signature: Signature from request header (e.g. 'sha256=abc123...')
        secret:    Shared webhook secret
        timestamp: Unix timestamp from request header (None to skip check)
        tolerance: Max age in seconds (default: 300 = 5 minutes)

    Returns:
        True if signature is valid, False otherwise
    """
    # Replay attack protection
    if timestamp is not None:
        age = abs(int(time.time()) - timestamp)
        if age > tolerance:
            return False

    # Build the signed payload
    if timestamp is not None:
        signed_payload = f"{timestamp}.".encode() + payload
    else:
        signed_payload = payload

    # Compute expected HMAC
    expected_digest = hmac.new(
        secret.encode('utf-8'),
        signed_payload,
        hashlib.sha256
    ).hexdigest()
    expected = f"sha256={expected_digest}"

    # CRITICAL: constant-time comparison to prevent timing attacks
    return hmac.compare_digest(expected, signature)


# Example usage — Flask webhook handler
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret_here"

@app.route('/webhook', methods=['POST'])
def webhook():
    payload = request.get_data()  # Raw bytes, before parsing
    signature = request.headers.get('X-Hub-Signature-256', '')

    if not verify_webhook_signature(payload, signature, WEBHOOK_SECRET):
        abort(401, "Invalid signature")

    data = request.get_json()
    # Safe to process...
    return '', 200

JavaScript / Node.js Implementation

const crypto = require('crypto');

/**
 * Verify an incoming webhook signature using HMAC-SHA256.
 *
 * @param {string|Buffer} payload   Raw request body (string or Buffer)
 * @param {string}        signature Signature header value (e.g. 'sha256=abc123...')
 * @param {string}        secret    Shared webhook secret
 * @param {number}        [timestamp]  Unix timestamp (seconds) — pass 0 or omit to skip
 * @param {number}        [tolerance]  Max age in seconds (default: 300)
 * @returns {boolean}
 */
function verifyWebhookSignature(payload, signature, secret, timestamp = 0, tolerance = 300) {
    // Replay attack protection
    if (timestamp > 0) {
        const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
        if (age > tolerance) {
            return false;
        }
    }

    // Build the signed payload (include timestamp if provided)
    const signedPayload = timestamp > 0
        ? `${timestamp}.${payload}`
        : payload;

    // Compute expected HMAC
    const expectedDigest = crypto
        .createHmac('sha256', secret)
        .update(signedPayload)
        .digest('hex');
    const expected = `sha256=${expectedDigest}`;

    // CRITICAL: constant-time comparison to prevent timing attacks
    // Buffers must be the same length for timingSafeEqual
    const a = Buffer.from(expected, 'utf8');
    const b = Buffer.from(signature, 'utf8');

    if (a.length !== b.length) {
        // Length difference already reveals mismatch, but we still
        // run the comparison to avoid timing leaks on length alone
        crypto.timingSafeEqual(a, Buffer.alloc(a.length));
        return false;
    }

    return crypto.timingSafeEqual(a, b);
}

// Example usage — Express.js webhook handler
const express = require('express');
const app = express();

// IMPORTANT: use raw body parser, not JSON parser
app.use('/webhook', express.raw({ type: 'application/json' }));

const WEBHOOK_SECRET = 'your_webhook_secret_here';

app.post('/webhook', (req, res) => {
    const payload = req.body;          // Buffer from raw parser
    const signature = req.headers['x-hub-signature-256'] || '';

    if (!verifyWebhookSignature(payload, signature, WEBHOOK_SECRET)) {
        return res.status(401).send('Invalid signature');
    }

    const data = JSON.parse(payload);
    // Safe to process...
    res.sendStatus(200);
});

Parse after verification, not before. Always verify the HMAC signature using the raw request body before parsing it as JSON. If you parse JSON first, the body middleware may re-serialize it slightly differently, causing the signature to fail even for valid requests.

Choosing the Right Hash Function

HMAC is not tied to a single hash function — you choose which one to use. The choice determines the output length, performance, and security level of your signatures.

Algorithm Output Size Security Level Recommendation
HMAC-MD5 128 bits (16 bytes) Broken Legacy compatibility only. MD5 has known collision attacks. Never use for new systems.
HMAC-SHA1 160 bits (20 bytes) Weak SHA-1 collisions are feasible. Avoid for new projects. Some older APIs (like early GitHub webhooks) still use it.
HMAC-SHA256 256 bits (32 bytes) Strong Recommended. The industry standard for webhooks and APIs. Used by GitHub, Stripe, Shopify, AWS.
HMAC-SHA384 384 bits (48 bytes) Strong Higher security margin. Reasonable choice if your threat model demands it.
HMAC-SHA512 512 bits (64 bytes) Maximum Highest security margin. Slightly faster than SHA-256 on 64-bit hardware. Use for high-assurance systems.

For most web applications and webhook verification use cases, HMAC-SHA256 is the correct choice. It is well-supported across every major language and platform, has no known practical attacks, and produces a compact 64-character hex signature.

Common Mistakes

1. Sending the Secret in the Request

This defeats the entire purpose of HMAC. The secret key must never leave your server — it is used to sign requests, not to authenticate them by transmission. If you are putting your secret in an HTTP header for the receiver to compare, you are doing this wrong. Use the secret to compute an HMAC and send the HMAC instead.

2. Not Including a Timestamp

An HMAC without a timestamp is vulnerable to replay attacks. If an attacker intercepts a valid signed request, they can re-send it hours or days later — the HMAC will still be valid because the payload has not changed. Always include the current Unix timestamp in the signed data and reject requests older than a few minutes (5 minutes is the standard window used by Stripe and GitHub).

3. Using Regular String Comparison

As explained in the timing attacks section: ===, ==, and strcmp are vulnerable. Always use hash_equals() (PHP), hmac.compare_digest() (Python), or crypto.timingSafeEqual() (Node.js).

4. Reusing the Same Secret Across Multiple Services

If you use the same HMAC secret for your payment webhook and your deployment webhook, a compromise of either service exposes both. Generate a separate, unique secret for each integration. Secrets are cheap; breaches are not.

5. Using a Short or Weak Secret Key

The security of HMAC depends entirely on the entropy of the secret key. A 6-character password is not a suitable HMAC key. The recommendation from NIST is that the secret key should be at least as long as the output of the hash function — so 32 bytes (256 bits) for HMAC-SHA256. Use a cryptographically secure random generator:

# Generate a strong 32-byte secret key

# PHP
$secret = bin2hex(random_bytes(32)); // 64-character hex string

# Python
import secrets
secret = secrets.token_hex(32)       # 64-character hex string

# Node.js
const secret = require('crypto').randomBytes(32).toString('hex');

# Bash / shell
openssl rand -hex 32

6. Verifying the Signature After Processing the Request

Verify first, act second — always. Do not insert records into your database, send emails, or trigger any side effects before you have confirmed the HMAC signature. An invalid signature means the request should be rejected immediately, before any business logic runs.

Real-World Examples

Looking at how major platforms implement HMAC helps you understand both the pattern and the variations you will encounter in practice.

GitHub Webhooks

GitHub sends a X-Hub-Signature-256 header with the format sha256=<hex_digest>. The signature is computed as HMAC-SHA256(secret, raw_body) — no timestamp is included in the signed data (GitHub handles replay prevention at the delivery level).

# GitHub webhook header example
X-Hub-Signature-256: sha256=d10f4b7d5c98a31c1f3b2e4d5a67890b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f

# Verification (PHP)
$expected = 'sha256=' . hash_hmac('sha256', $raw_body, $secret);
$valid = hash_equals($expected, $_SERVER['HTTP_X_HUB_SIGNATURE_256']);

Stripe Webhooks

Stripe's approach is more comprehensive. The Stripe-Signature header contains both the timestamp and one or more v1 HMAC signatures. The signed payload is timestamp.raw_body. Multiple signatures may appear during secret rotation.

# Stripe webhook header example
Stripe-Signature: t=1706123456,v1=abc123...,v1=def456...

# Stripe-style verification (PHP)
function verifyStripeSignature(string $payload, string $sig_header, string $secret): bool {
    $parts = [];
    foreach (explode(',', $sig_header) as $part) {
        [$k, $v] = explode('=', $part, 2);
        $parts[$k][] = $v;
    }

    $timestamp = (int)($parts['t'][0] ?? 0);
    if (abs(time() - $timestamp) > 300) return false; // 5-minute tolerance

    $signed = $timestamp . '.' . $payload;
    $expected = hash_hmac('sha256', $signed, $secret);

    foreach ($parts['v1'] ?? [] as $sig) {
        if (hash_equals($expected, $sig)) return true;
    }
    return false;
}

AWS Signature Version 4

AWS takes HMAC to its most sophisticated form. AWS SigV4 derives a signing key through a chain of four nested HMACs before signing the final request. This derives a date-scoped, region-scoped, service-scoped key from the main secret:

# AWS SigV4 key derivation (simplified)
# Each step uses the previous result as the key for the next HMAC

date_key     = HMAC-SHA256("AWS4" + secret_key,   date)
region_key   = HMAC-SHA256(date_key,              region)
service_key  = HMAC-SHA256(region_key,            service)
signing_key  = HMAC-SHA256(service_key,           "aws4_request")

# The signing key is then used to sign the "string to sign"
signature    = HMAC-SHA256(signing_key,           string_to_sign)

This chained approach means that even if a day-scoped key is leaked, it cannot be used to sign requests for other services, regions, or days. It demonstrates how HMAC can be used as a key derivation function (KDF) as well as a MAC.

HMAC secures the channel between your servers. For protecting the network layer — encrypting traffic in transit and blocking threats before they reach your infrastructure — NordVPN Threat Protection adds a complementary defense layer that works independently of your application code.

Related Security Reads

Want to try it? Compute and verify HMAC-SHA256/SHA512 signatures in your browser with the free HMAC Generator — nothing is sent to a server.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.