How HMAC API Signing Works
Request signing with shared secrets, replay attack prevention, and webhook verification — a practical walkthrough.
Last updated: Mar 2, 2026
Part of the API Authentication Methods guide.
The secret is sent on the wire directly. An intercepted key is immediately reusable.
Risk: Capture → replay forever
The secret never leaves your server. Each request gets a unique signature over its content + timestamp.
Risk mitigated: Capture → useless after replay window
Uses asymmetric keys — private key signs, public key verifies. No shared secret needed.
Used for: JWTs (RS256/ES256), code signing, TLS
Why Use HMAC Signing Instead of a Plain API Key?
When you send an API key in a request header, you are transmitting the secret itself. If an attacker intercepts the request (via a compromised proxy, logging system, or network position), they have your credentials and can make arbitrary requests.
HMAC signing changes the model. Instead of sending the secret, you use it to compute a keyed hash (HMAC) over specific request components — the method, path, headers, body, and a timestamp. You send the signature; the secret stays on your server. The receiving server recomputes the HMAC and compares — if it matches, the request is authentic and unmodified.
Including a timestamp in the signed content adds replay protection. A captured request contains a signature that is only valid for a short window (typically 5 minutes). After that, the timestamp is too old and the server rejects it.
HMAC vs plain hash — a critical difference
A plain SHA-256 hash can be computed by anyone who has the data — no secret needed. HMAC requires a shared secret key, so only parties who hold the key can generate a valid signature. For a full comparison, see SHA-256 vs HMAC-SHA256.
Step-by-Step: How Request Signing Works
Build the canonical string
Assemble the components to be signed into a single normalized string. The exact format varies by system, but typically includes the HTTP method, path, relevant headers, the body hash, and a Unix timestamp.
POST /api/payments content-type:application/json x-timestamp:1709500000 sha256:e3b0c44298fc1c149afb... ← SHA-256 of body
Compute the HMAC
Apply HMAC-SHA256 (or HMAC-SHA512) to the canonical string using your secret key. The output is a 32-byte (256-bit) value, typically hex- or base64-encoded.
signature = HMAC-SHA256(key=secret, data=canonicalString) encoded = hex(signature) // → "a3f2b8c9d4e1..." (64 hex chars)
Send the signature in the request
Include the signature and the key ID (or API key) in the request headers. The timestamp must also be present so the server can validate the replay window.
POST /api/payments HTTP/1.1 Authorization: HMAC-SHA256 keyId="ak_abc",signature="a3f2b8c9..." X-Timestamp: 1709500000 Content-Type: application/json
Server verifies
The server looks up the secret for the given key ID, checks that the timestamp is within the replay window (e.g., ±5 minutes), rebuilds the canonical string from the incoming request, recomputes the HMAC, and compares using a constant-time comparison function to prevent timing attacks.
Use constant-time comparison
Never compare HMAC signatures with === or strcmp. Short-circuit comparison leaks timing information that attackers can exploit to guess signatures byte by byte. Use crypto.timingSafeEqual (Node.js), hmac.compare_digest (Python), or equivalent.
Webhook Signature Verification
Webhooks are the most common place developers encounter HMAC signatures. When Stripe charges a card, GitHub merges a PR, or Twilio receives an SMS, they POST a payload to your endpoint. Without signature verification, anyone on the internet can forge that payload and trigger your code.
The provider computes an HMAC of the raw request body using a secret you configure in their dashboard. They include the resulting signature in a request header. Your endpoint must verify this before processing the event.
Stripe sends the Stripe-Signature header containing a timestamp and HMAC-SHA256 signature. The signed payload is timestamp.rawBody.
Stripe-Signature: t=1709500000,v1=a3f2b8c9d4e1...
// Verify:
const payload = `${timestamp}.${rawBody}`
const expected = HMAC-SHA256(webhookSecret, payload)
const valid = timingSafeEqual(expected, v1Signature)Always use the raw body — not a parsed JSON object. JSON serialization can change whitespace and key ordering, invalidating the signature.
GitHub sends X-Hub-Signature-256 with a hex HMAC-SHA256 prefixed by sha256=. The signed data is the raw body.
X-Hub-Signature-256: sha256=a3f2b8c9d4e1...
// Verify (Node.js):
const sig = crypto.createHmac('sha256', secret)
.update(rawBody).digest('hex')
const valid = crypto.timingSafeEqual(
Buffer.from(`sha256=${sig}`),
Buffer.from(req.headers['x-hub-signature-256'])
)AWS uses a multi-step HMAC-SHA256 signing process. The signing key is derived from the secret access key, date, region, and service — new each day. This reduces the blast radius if a derived key leaks.
signingKey = HMAC(
HMAC(HMAC(HMAC("AWS4" + secretKey, date), region),
service), "aws4_request"
)
signature = HMAC(signingKey, stringToSign)The canonical request covers the HTTP method, URI, query string, signed headers, and body hash — covering every meaningful aspect of the request.
Test your webhook signatures with the Webhook Signature Verifier. It computes and verifies HMAC-SHA256, SHA-512, and SHA-1 signatures in your browser using the Web Crypto API — your secret never leaves your machine.
When to Use HMAC Signing
| Use Case | Recommendation |
|---|---|
| Verify an incoming webhook payload | HMAC (required) |
| High-security financial API (payment processing) | HMAC request signing |
| Cloud provider API calls (AWS, GCP) | HMAC (provider-specific format) |
| Simple public REST API with rate limiting | API key (simpler, sufficient) |
| User session / browser auth | JWT or session cookie |
| Verifying message integrity + authenticity | HMAC |
| Multiple services verifying tokens without shared secret | Digital signature (RS256/ES256 JWT) |
Implementation Patterns
import crypto from 'node:crypto'
// Compute
const signature = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(rawBody) // raw Buffer, not parsed JSON
.digest('hex')
// Verify (constant-time)
const trusted = Buffer.from(expectedSig, 'hex')
const received = Buffer.from(signature, 'hex')
const valid = crypto.timingSafeEqual(trusted, received)async function computeHmac(secret: string, message: string) {
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw', enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false, ['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message))
// Convert ArrayBuffer to hex
return Array.from(new Uint8Array(sig))
.map(b => b.toString(16).padStart(2, '0')).join('')
}The Web Crypto API runs entirely in the browser — no server call needed. The Webhook Signature Verifier uses this exact pattern.
import hmac, hashlib, time
def verify_stripe_webhook(payload: bytes, sig_header: str, secret: str) -> bool:
parts = dict(p.split('=', 1) for p in sig_header.split(','))
timestamp = int(parts['t'])
# Reject if timestamp is > 5 minutes old
if abs(time.time() - timestamp) > 300:
return False
signed_payload = f"{timestamp}.".encode() + payload
expected = hmac.new(
secret.encode(), signed_payload, hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected, parts['v1'])Common Mistakes
Parsing JSON before verification
The HMAC is computed over the raw bytes of the request body. Parsing the body to JSON and re-serializing it changes whitespace and key order, making the signatures mismatch. Always buffer the raw body before parsing.
Using string equality to compare signatures
Regular string comparison short-circuits at the first mismatched byte, leaking information about how much of the signature is correct. Always use a constant-time comparison function.
Not validating the timestamp
Without timestamp validation, a captured request signature can be replayed indefinitely. Reject requests whose timestamp is more than 5–10 minutes outside the current time. Ensure server clocks are NTP-synchronized.
Signing only partial request content
If the signature covers the path but not the body, an attacker can keep the path the same and modify the body freely. Sign all security-relevant components: method, path, relevant headers, body, and timestamp.
Using a weak or short secret key
The HMAC secret should be at least 32 bytes of cryptographically random data (256 bits). A short or low-entropy secret (like a user-chosen password) makes the signature weak. Use a proper CSPRNG to generate secrets.
Frequently Asked Questions
Try These Tools
Related Guides and Comparisons
API Authentication Methods
API keys, JWT, OAuth 2.0, and HMAC signing — choose the right approach
Hashing and HMAC
Foundational concepts: what hashing and HMAC are, and when to use each
SHA-256 vs HMAC-SHA256
Why a keyed hash proves authenticity but a plain hash doesn't
API Key vs JWT
Side-by-side: when simple API keys win, when JWTs are the better choice
JWT vs Session Cookies
Stateless tokens vs server-side sessions — trade-offs and when to use each
What Is a Digital Signature?
How HMAC and asymmetric signatures differ — and when each is appropriate