Guide
API
Security
Developer Reference

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.

Plain API Key

The secret is sent on the wire directly. An intercepted key is immediately reusable.

Risk: Capture → replay forever

HMAC Signing

The secret never leaves your server. Each request gets a unique signature over its content + timestamp.

Risk mitigated: Capture → useless after replay window

Digital Signature

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

1

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
2

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)
3

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
4

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 Webhook Verification

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 Webhook Verification

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 Signature Version 4

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 CaseRecommendation
Verify an incoming webhook payloadHMAC (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 limitingAPI key (simpler, sufficient)
User session / browser authJWT or session cookie
Verifying message integrity + authenticityHMAC
Multiple services verifying tokens without shared secretDigital signature (RS256/ES256 JWT)

Implementation Patterns

Node.js — Compute HMAC-SHA256
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)
Browser — Web Crypto API (HMAC-SHA256)
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.

Python — Verify Stripe Webhook
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

X

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.

X

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.

X

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.

X

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.

X

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