StackPractices
intermediate By Mathias Paulenko

HMAC Request Signing

Secure API requests with HMAC-SHA256 signatures to ensure integrity and authenticity.

Topics: security

Overview

HMAC (Hash-based Message Authentication Code) is the industry standard for signing API requests. By combining a shared secret with the request payload and a cryptographic hash, both sender and receiver can verify message integrity and authenticity without transmitting the secret over the wire.

When to Use

Use this resource when:

  • Authenticating service-to-service API calls
  • Ensuring webhook payloads have not been tampered with
  • Implementing API key authentication without OAuth complexity
  • Verifying request integrity across untrusted networks

Solution

HMAC-SHA256 Signing (Node.js)

const crypto = require('crypto');

function signRequest(method, path, body, timestamp, secret) {
  const payload = method.toUpperCase() + path + timestamp + JSON.stringify(body);
  return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}

function verifyRequest(method, path, body, timestamp, signature, secret) {
  const expected = signRequest(method, path, body, timestamp, secret);
  // Constant-time comparison
  return crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex')
  );
}

Client-Server Example (Python)

import hmac
import hashlib
import time

def sign_request(method: str, path: str, body: bytes, secret: str) -> str:
    timestamp = str(int(time.time()))
    message = f"{method.upper()}{path}{timestamp}{body.decode()}"
    signature = hmac.new(
        secret.encode(),
        message.encode(),
        hashlib.sha256
    ).hexdigest()
    return signature, timestamp

# Client
signature, ts = sign_request("POST", "/api/orders", b'{"id":1}', "my-secret")
headers = {"X-Signature": signature, "X-Timestamp": ts}

# Server
def verify(signature: str, timestamp: str, method, path, body, secret):
    # Reject old requests (replay protection)
    if abs(int(time.time()) - int(timestamp)) > 300:
        return False
    expected, _ = sign_request(method, path, body, secret)
    return hmac.compare_digest(signature, expected)

Explanation

HMAC security relies on three properties:

  1. Secret key: Never transmitted; shared out-of-band during onboarding
  2. Message coverage: The signature must cover method, path, timestamp, and body
  3. Replay protection: Timestamp windows prevent attackers from reusing old requests

Why not plain SHA-256? SHA-256 without HMAC is vulnerable to length-extension attacks. HMAC uses two nested hash passes that prevent this.

Variants

AlgorithmHashStrengthNotes
HMAC-SHA256SHA-256128-bitRecommended default
HMAC-SHA384SHA-384192-bitHigher security margin
HMAC-SHA512SHA-512256-bitSlower; use for high-security contexts
HMAC-Blake3Blake3256-bitFast; modern alternative

What Works

  • Include timestamp: Reject requests older than 5 minutes to prevent replay attacks
  • Sign the entire request: Method + path + timestamp + body (sorted headers if included)
  • Use constant-time comparison: timingSafeEqual prevents timing attacks
  • Rotate secrets regularly: Use key versioning (v1, v2) in the signature header
  • Never log the secret: Log signatures and keys, never the raw secret

Common Mistakes

  1. Signing only the body: An attacker can replay a valid body with a different endpoint
  2. Missing replay protection: Without timestamps, intercepted requests are valid forever
  3. Using MD5 or SHA-1: Cryptographically broken; use SHA-256 minimum
  4. String comparison instead of timingSafeEqual: Vulnerable to timing attacks
  5. Storing secrets in environment variables without encryption: Use a secret manager

Advanced Solutions

Java HMAC-SHA256 signing with key rotation

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class HmacSigner {

    private final Map<String, String> secrets = new HashMap<>();

    public HmacSigner() {
        secrets.put("v1", "old-secret-key");
        secrets.put("v2", "current-secret-key");
    }

    public String sign(String method, String path, String body, String timestamp, String keyVersion)
            throws Exception {
        String payload = method.toUpperCase() + path + timestamp + body;
        Mac mac = Mac.getInstance("HmacSHA256");
        SecretKeySpec keySpec = new SecretKeySpec(
            secrets.get(keyVersion).getBytes(StandardCharsets.UTF_8),
            "HmacSHA256"
        );
        mac.init(keySpec);
        byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
        return keyVersion + ":" + Base64.getEncoder().encodeToString(hash);
    }

    public boolean verify(String method, String path, String body, String timestamp, String signature)
            throws Exception {
        // Parse key version from signature: "v2:base64hash"
        String[] parts = signature.split(":", 2);
        if (parts.length != 2) return false;

        String keyVersion = parts[0];
        String receivedHash = parts[1];

        if (!secrets.containsKey(keyVersion)) return false;

        String expected = sign(method, path, body, timestamp, keyVersion);
        String expectedHash = expected.split(":", 2)[1];

        // Constant-time comparison
        return MessageDigest.isEqual(
            receivedHash.getBytes(StandardCharsets.UTF_8),
            expectedHash.getBytes(StandardCharsets.UTF_8)
        );
    }
}

// Usage
HmacSigner signer = new HmacSigner();
String sig = signer.sign("POST", "/api/orders", "{\"id\":1}", "1690000000", "v2");
// Headers: X-Signature: v2:base64hash, X-Timestamp: 1690000000

Webhook signature verification with raw body

Many APIs (Stripe, GitHub, Slack) sign webhooks with HMAC. You must verify using the raw request body before any JSON parsing:

const crypto = require('crypto');

/**
 * Verify a Stripe-style webhook signature.
 * Stripe uses: t=<timestamp>,v1=<signature>
 */
function verifyWebhook(rawBody, signatureHeader, secret) {
    const parts = signatureHeader.split(',');
    const timestamp = parts.find(p => p.startsWith('t='))?.split('=')[1];
    const signatures = parts
        .filter(p => p.startsWith('v1='))
        .map(p => p.split('=')[1]);

    if (!timestamp || signatures.length === 0) return false;

    // Reject old timestamps (5 minute window)
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
    if (age > 300 || age < -300) return false;

    // Compute expected signature: HMAC-SHA256(timestamp + rawBody)
    const payload = `${timestamp}.${rawBody}`;
    const expected = crypto
        .createHmac('sha256', secret)
        .update(payload, 'utf8')
        .digest('hex');

    // Check against all provided signatures (Stripe may send multiple)
    return signatures.some(sig =>
        crypto.timingSafeEqual(
            Buffer.from(sig, 'hex'),
            Buffer.from(expected, 'hex')
        )
    );
}

// Express middleware: must use raw body
const express = require('express');
const app = express();

// IMPORTANT: use raw body for signature verification
app.use('/webhooks', express.raw({ type: 'application/json' }));

app.post('/webhooks/stripe', (req, res) => {
    const rawBody = req.body.toString('utf8');
    const sig = req.headers['stripe-signature'];

    if (!verifyWebhook(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET)) {
        return res.status(400).send('Invalid signature');
    }

    const event = JSON.parse(rawBody);
    console.log('Verified webhook:', event.type);
    res.status(200).send('OK');
});

Nonce-based replay prevention

Timestamps alone allow a 5-minute replay window. Add a nonce cache to reject duplicate requests within that window:

import hmac
import hashlib
import time
from collections import OrderedDict
from typing import Optional


class NonceCache:
    """LRU cache for tracking used nonces within the timestamp window."""

    def __init__(self, max_size: int = 10000, ttl_seconds: int = 300):
        self._cache: OrderedDict[str, float] = OrderedDict()
        self._max_size = max_size
        self._ttl = ttl_seconds

    def check_and_add(self, nonce: str) -> bool:
        """Returns True if nonce is new (acceptable), False if duplicate."""
        now = time.time()
        self._evict_expired(now)

        if nonce in self._cache:
            return False  # Duplicate nonce

        self._cache[nonce] = now
        if len(self._cache) > self._max_size:
            self._cache.popitem(last=False)
        return True

    def _evict_expired(self, now: float):
        expired = [
            k for k, ts in self._cache.items()
            if now - ts > self._ttl
        ]
        for k in expired:
            self._cache.pop(k, None)


class HmacVerifier:
    """HMAC verifier with nonce-based replay prevention."""

    def __init__(self, secret: str, max_skew_seconds: int = 300):
        self.secret = secret.encode()
        self.max_skew = max_skew_seconds
        self.nonces = NonceCache(max_size=10000, ttl_seconds=max_skew_seconds)

    def verify(
        self,
        method: str,
        path: str,
        body: str,
        timestamp: str,
        nonce: str,
        signature: str,
    ) -> bool:
        # Check timestamp window
        try:
            ts = int(timestamp)
        except ValueError:
            return False

        if abs(int(time.time()) - ts) > self.max_skew:
            return False

        # Check nonce uniqueness
        if not self.nonces.check_and_add(nonce):
            return False

        # Verify signature
        message = f"{method.upper()}{path}{timestamp}{nonce}{body}"
        expected = hmac.new(
            self.secret,
            message.encode(),
            hashlib.sha256
        ).hexdigest()

        return hmac.compare_digest(signature, expected)


# Server-side usage
verifier = HmacVerifier("my-secret-key")

# Request arrives with headers:
# X-Timestamp: 1690000000
# X-Nonce: a1b2c3d4-e5f6-7890-abcd-ef1234567890
# X-Signature: hexhash

is_valid = verifier.verify(
    method="POST",
    path="/api/orders",
    body='{"id":1,"item":"widget"}',
    timestamp="1690000000",
    nonce="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    signature="abc123...",
)

Key rotation strategy

import hmac
import hashlib
import time
from typing import Optional


class KeyRotator:
    """Manages HMAC key rotation with overlap period for zero downtime."""

    def __init__(self):
        self.keys: dict[str, dict] = {}
        self.active_version: Optional[str] = None

    def add_key(self, version: str, secret: str, activate: bool = True):
        self.keys[version] = {
            "secret": secret,
            "created": time.time(),
        }
        if activate:
            self.active_version = version

    def deactivate_key(self, version: str):
        self.keys.pop(version, None)
        if self.active_version == version:
            # Activate the most recent remaining key
            if self.keys:
                self.active_version = max(self.keys.keys())
            else:
                self.active_version = None

    def get_signing_key(self) -> tuple[str, str]:
        """Returns (version, secret) for signing new requests."""
        if not self.active_version:
            raise RuntimeError("No active signing key")
        return self.active_version, self.keys[self.active_version]["secret"]

    def get_verification_keys(self) -> list[tuple[str, str]]:
        """Returns all valid keys for verifying incoming requests."""
        return [(v, info["secret"]) for v, info in self.keys.items()]

    def sign(self, method: str, path: str, body: str, timestamp: str) -> str:
        version, secret = self.get_signing_key()
        message = f"{method.upper()}{path}{timestamp}{body}"
        sig = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
        return f"{version}:{sig}"

    def verify(self, method: str, path: str, body: str, timestamp: str, signature: str) -> bool:
        parts = signature.split(":", 1)
        if len(parts) != 2:
            return False

        version, received_sig = parts
        for v, secret in self.get_verification_keys():
            if v != version:
                continue
            message = f"{method.upper()}{path}{timestamp}{body}"
            expected = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
            if hmac.compare_digest(received_sig, expected):
                return True
        return False


# Rotation workflow:
# 1. Add new key (v2) — both v1 and v2 are valid for verification
rotator = KeyRotator()
rotator.add_key("v1", "old-secret", activate=False)
rotator.add_key("v2", "new-secret", activate=True)

# 2. Deploy: new requests signed with v2, old v1 requests still verify
# 3. After all old requests expire (timestamp window), deactivate v1
# rotator.deactivate_key("v1")

Frequently Asked Questions

Is HMAC better than JWT for service-to-service auth?

HMAC is simpler and stateless for internal services. JWT is better when you need identity claims and third-party verification. For a full API security overview, see the API security checklist.

How do I handle clock skew between services?

Allow a 5-minute window and synchronize with NTP. Reject requests outside the window.

Can I use the same secret for multiple clients?

No. Each client should have a unique secret so you can revoke one without affecting others.