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 byte0x36repeated 64 times -
opad— outer padding: the byte0x5Crepeated 64 times -
⊕— XOR operation -
||— concatenation
Step-by-Step Walkthrough
- 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.
-
Inner hash: XOR the padded key with
ipad, concatenate the message, and hash the result:H((K' ⊕ ipad) || m). -
Outer hash: XOR the padded key with
opad, concatenate the inner hash result, and hash again:H((K' ⊕ opad) || inner_hash). - 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)orH(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:
- When you register a webhook endpoint, you provide (or receive) a shared secret.
- When the sender fires a webhook, it computes
HMAC-SHA256(secret, payload)— often including a timestamp in the signed data. - The sender includes the signature in a request header (e.g.
X-Hub-Signature-256,Stripe-Signature). - Your server receives the request, independently computes the same HMAC, and compares signatures using constant-time comparison.
- 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
- HTTP Security Headers: Complete Developer Guide
- X.509 Certificates & SSL/TLS Explained Simply
- Content Security Policy (CSP): Stop XSS Attacks
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.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.