StackPractices
advanced By Mathias Paulenko

HMAC Request Signing for API Authentication (AWS Sig v4)

Secure API requests with HMAC signatures and AWS Signature v4 authentication for tamper-proof message integrity.

Topics: security

Overview

TLS protects bytes in transit, but it can’t prove the client holding them is the one you trust — or that a captured request wasn’t replayed an hour later. HMAC-SHA256 signing closes that gap: client and server share a secret, the client signs a canonical form of the request, and the server re-computes the signature on receipt. One mismatched byte and the request dies before it touches your handler.

The recipes below build that in Python, JavaScript, Java, and Go, then go further: a full AWS Signature v4 implementation, a webhook verifier with nonce-based replay protection, and a key-rotation scheme that doesn’t drop clients mid-rotation.

When to Use

Reach for HMAC signing when:

  • You deliver or consume webhooks and need to verify the sender (Stripe, GitHub and Shopify all sign theirs this way)
  • You run machine-to-machine APIs where a shared secret fits better than OAuth token flows
  • You’re implementing AWS-compatible authentication — S3, API Gateway and most AWS services use SigV4
  • You need stateless auth: no session store, just a signature the server can check on its own

Where it’s the wrong tool:

  • Requests carry data that must stay confidential — HMAC signs, it doesn’t encrypt; you still need TLS
  • The client can’t hold a secret securely (public SPA, mobile app shipped to users) — look at asymmetric signatures (Ed25519) or OAuth instead
  • You need non-repudiation — a shared secret means either party could have produced the signature

Solution

Python

import hashlib
import hmac
from datetime import datetime, timezone

def sign_request(secret: str, method: str, path: str, body: str = "") -> dict:
    timestamp = datetime.now(timezone.utc).isoformat()
    message = f"{method}\n{path}\n{timestamp}\n{body}"
    signature = hmac.new(
        secret.encode(),
        message.encode(),
        hashlib.sha256
    ).hexdigest()

    return {
        "X-Request-Timestamp": timestamp,
        "X-Request-Signature": signature,
    }

# Client usage
import requests

headers = sign_request("my-secret-key", "POST", "/api/orders", '{"item": "book"}')
requests.post("https://api.example.com/api/orders",
              headers=headers, data='{"item": "book"}')

JavaScript

const crypto = require('crypto');

function signRequest(secret, method, path, body = '') {
  const timestamp = new Date().toISOString();
  const message = `${method}\n${path}\n${timestamp}\n${body}`;

  const signature = crypto
    .createHmac('sha256', secret)
    .update(message)
    .digest('hex');

  return {
    'X-Request-Timestamp': timestamp,
    'X-Request-Signature': signature,
  };
}

// Server verification
function verifyRequest(secret, headers, method, path, body) {
  const provided = headers['x-request-signature'];
  if (typeof provided !== 'string') return false;

  const expected = signRequest(secret, method, path, body)['X-Request-Signature'];
  const providedBuf = Buffer.from(provided);
  const expectedBuf = Buffer.from(expected);

  // timingSafeEqual throws on length mismatch — check first
  return providedBuf.length === expectedBuf.length
    && crypto.timingSafeEqual(providedBuf, expectedBuf);
}

Java

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;

public class RequestSigner {
    private static final String HMAC_ALGO = "HmacSHA256";

    // Returns both headers — the server needs the timestamp to
    // reconstruct the signed message
    public static Map<String, String> signHeaders(
            String secret, String method, String path, String body) throws Exception {
        String timestamp = Instant.now().toString();
        String message = String.join("\n", method, path, timestamp, body);

        Mac mac = Mac.getInstance(HMAC_ALGO);
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGO));
        byte[] signature = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));

        return Map.of(
            "X-Request-Timestamp", timestamp,
            "X-Request-Signature", Base64.getEncoder().encodeToString(signature)
        );
    }
}

Explanation

The flow is the same in every language — four steps and one trap:

flowchart diagram: Client builds canonical string:<br/>method + path + timestamp + body
  1. Canonicalize: concatenate a fixed set of fields — method, path, timestamp, body — into one byte-exact string. Every party must build it identically, which is why the canonical form is documented as part of your API contract.
  2. Sign: compute HMAC-SHA256(secret, canonicalString). HMAC wraps the hash in a key-specific structure, which is what blocks the length-extension attacks a raw SHA-256(secret + message) digest is open to.
  3. Attach: both the signature and its timestamp go out as headers — the timestamp doubles as replay protection, so reject anything older than your window.
  4. Verify: rebuild the same string from the request that arrived, sign it, compare in constant time.

The trap: timingSafeEqual and friends throw on a length mismatch rather than returning false. A missing or truncated signature header becomes an unhandled 500 unless you compare lengths first — the JavaScript verifier above shows the guard.

Variants

AlgorithmKey TypeStrengthUse Case
HMAC-SHA256Shared secret256-bitAPI authentication, webhooks
AWS SigV4IAM credentials256-bitAWS service compatibility
Ed25519Asymmetric128-bitPublic/private key verification
RSA-SHA256Asymmetric2048+ bitEnterprise PKI integration

HMAC-SHA256 wins on simplicity and speed when both sides can hold a secret. Ed25519 or RSA make sense once you can’t distribute the signing key — public webhooks where clients publish a public key, or clients you don’t fully trust with a shared credential.

Advanced Solutions

AWS Signature v4 implementation (Python)

SigV4 wraps HMAC in a scoped credential chain: the key that signs the request is derived per date, region, and service, and the canonical request pins down the exact headers and query params covered. Note the query string normalization — AWS requires params URI-encoded and sorted:

import hashlib
import hmac
import urllib.parse
from datetime import datetime, timezone

def get_aws_signature_v4(
    access_key: str,
    secret_key: str,
    region: str,
    service: str,
    method: str,
    url: str,
    headers: dict,
    body: str = '',
) -> dict:
    """Generate AWS Signature v4 headers for an API request."""
    parsed = urllib.parse.urlparse(url)
    host = parsed.netloc
    path = parsed.path or '/'

    # Canonical query: URI-encode each name/value, then sort
    pairs = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
    canonical_query = '&'.join(
        f'{urllib.parse.quote(k, safe="")}={urllib.parse.quote(v, safe="")}'
        for k, v in sorted(pairs)
    )

    # Step 1: Create canonical request
    amz_date = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
    date_stamp = amz_date[:8]

    headers = dict(headers)
    headers['Host'] = host  # Host is always signed in SigV4
    signed_headers = sorted(headers.keys())
    canonical_headers = ''.join(
        f'{h.lower()}:{headers[h].strip()}\n' for h in signed_headers
    )
    signed_header_str = ';'.join(h.lower() for h in signed_headers)

    payload_hash = hashlib.sha256(body.encode()).hexdigest()

    canonical_request = '\n'.join([
        method.upper(),
        path,
        canonical_query,
        canonical_headers,
        signed_header_str,
        payload_hash,
    ])

    # Step 2: Create string to sign
    credential_scope = f'{date_stamp}/{region}/{service}/aws4_request'
    string_to_sign = '\n'.join([
        'AWS4-HMAC-SHA256',
        amz_date,
        credential_scope,
        hashlib.sha256(canonical_request.encode()).hexdigest(),
    ])

    # Step 3: Derive the signing key (HMAC chain: date → region → service)
    def sign(key: bytes, msg: str) -> bytes:
        return hmac.new(key, msg.encode(), hashlib.sha256).digest()

    signing_key = sign(
        sign(sign(sign(
            ('AWS4' + secret_key).encode(), date_stamp),
            region),
            service),
        'aws4_request'
    )

    signature = hmac.new(
        signing_key, string_to_sign.encode(), hashlib.sha256
    ).hexdigest()

    # Step 4: Build authorization header
    result = dict(headers)
    result['Authorization'] = (
        f'AWS4-HMAC-SHA256 '
        f'Credential={access_key}/{credential_scope}, '
        f'SignedHeaders={signed_header_str}, '
        f'Signature={signature}'
    )
    result['X-Amz-Date'] = amz_date
    result['X-Amz-Content-Sha256'] = payload_hash
    return result

# Usage
headers = get_aws_signature_v4(
    access_key='AKIAIOSFODNN7EXAMPLE',
    secret_key='wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
    region='us-east-1',
    service='s3',
    method='GET',
    url='https://s3.amazonaws.com/my-bucket/object.txt',
    headers={},
)

In real integrations prefer the AWS SDK or botocore’s signer — hand-rolled SigV4 exists mostly for interop testing and understanding what the SDK hides.

Webhook signature verification with nonce (Node.js)

A fresh timestamp alone still leaves a gap where a captured request replays. A nonce tracked in Redis closes it — each nonce is accepted exactly once:

const crypto = require('crypto');
const express = require('express');
const redis = require('redis');

const REPLAY_WINDOW = 5 * 60; // 5 minutes
const app = express();
const redisClient = redis.createClient({ url: process.env.REDIS_URL });

async function verifyWebhook(secret, req) {
  const signature = req.headers['x-webhook-signature'];
  const timestamp = req.headers['x-webhook-timestamp'];
  const nonce = req.headers['x-webhook-nonce'];
  const body = req.body; // Buffer — needs express.raw(), not parsed JSON

  // 1. Check timestamp freshness
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp, 10)) > REPLAY_WINDOW) {
    throw new Error('Timestamp outside replay window');
  }

  // 2. Check nonce uniqueness — SET NX is atomic, so two identical
  //    requests racing in can't both pass
  const nonceKey = `webhook:nonce:${nonce}`;
  const created = await redisClient.set(nonceKey, '1', {
    NX: true,
    EX: REPLAY_WINDOW,
  });
  if (!created) {
    throw new Error('Nonce already used — possible replay attack');
  }

  // 3. Verify signature
  const message = `${timestamp}.${nonce}.${body.toString('utf-8')}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(message)
    .digest('base64');

  const a = Buffer.from(signature || '');
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error('Invalid signature');
  }
}

// express.raw gives req.body as a Buffer — a JSON parser would change
// the bytes and break signature verification
app.post('/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    try {
      await verifyWebhook(process.env.WEBHOOK_SECRET, req);
      res.status(200).json({ ok: true });
    } catch (err) {
      res.status(401).json({ error: err.message });
    }
  },
);

async function main() {
  await redisClient.connect();
  app.listen(3000);
}
main();

Go HMAC request signing

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"net/http"
	"time"
)

func SignRequest(secret, method, path, body string) http.Header {
	timestamp := time.Now().UTC().Format(time.RFC3339)
	message := fmt.Sprintf("%s\n%s\n%s\n%s", method, path, timestamp, body)

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(message))
	signature := hex.EncodeToString(mac.Sum(nil))

	header := http.Header{}
	header.Set("X-Request-Timestamp", timestamp)
	header.Set("X-Request-Signature", signature)
	return header
}

func VerifyRequest(secret string, r *http.Request, body []byte) bool {
	timestamp := r.Header.Get("X-Request-Timestamp")
	signature := r.Header.Get("X-Request-Signature")

	// Check timestamp freshness
	ts, err := time.Parse(time.RFC3339, timestamp)
	if err != nil {
		return false
	}
	if time.Since(ts) > 5*time.Minute {
		return false
	}

	message := fmt.Sprintf("%s\n%s\n%s\n%s",
		r.Method, r.URL.Path, timestamp, string(body))

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(message))
	expected := hex.EncodeToString(mac.Sum(nil))

	// Constant-time comparison
	return hmac.Equal([]byte(signature), []byte(expected))
}

Key rotation strategy (Python)

Rotating the shared secret breaks every in-flight signature unless both keys are valid during an overlap window:

import hmac
import hashlib
import time
from dataclasses import dataclass

@dataclass
class KeyRotation:
    """Manage HMAC key rotation with overlap period."""
    current_key: str
    previous_key: str | None = None
    rotation_time: float = 0
    overlap_seconds: int = 3600  # 1 hour overlap

    def rotate(self, new_key: str):
        """Rotate to a new key, keeping old key valid during overlap."""
        self.previous_key = self.current_key
        self.current_key = new_key
        self.rotation_time = time.time()

    def get_valid_keys(self) -> list[str]:
        """Return all currently valid keys."""
        keys = [self.current_key]
        if self.previous_key and self.previous_key != self.current_key:
            if time.time() - self.rotation_time < self.overlap_seconds:
                keys.append(self.previous_key)
        return keys

    def verify(self, signature: str, method: str, path: str,
               timestamp: str, body: str) -> bool:
        """Verify against any valid key (supports rotation overlap)."""
        message = f"{method}\n{path}\n{timestamp}\n{body}"

        for key in self.get_valid_keys():
            expected = hmac.new(
                key.encode(), message.encode(), hashlib.sha256
            ).hexdigest()
            if hmac.compare_digest(signature, expected):
                return True
        return False

# Usage
rotation = KeyRotation(current_key='secret-v1')
# After rotation: both v1 and v2 are valid for 1 hour
rotation.rotate('secret-v2')
# After 1 hour: only v2 is valid

What Works

  • Compare with a timing-safe primitive (timingSafeEqual, hmac.compare_digest, hmac.Equal) — and check buffer lengths first, because they throw on mismatched sizes.
  • Sign a canonical string that includes the timestamp and the raw body; reject anything older than ~5 minutes to bound replay.
  • Rotate secrets with an overlap window where the old and new key both verify — one hard cutover drops every client mid-request.
  • Keep per-client secrets: a compromised client rotates alone instead of forcing a global key change.
  • Store secrets in a vault (HashiCorp Vault or AWS Secrets Manager), never in code or config files.

Common Mistakes

  • MD5 or SHA1 as the hash. Both are cryptographically broken — SHA-256 is the floor.
  • Plain == comparison. String equality short-circuits on the first differing byte and leaks timing information; use the constant-time compare.
  • Signing only the headers. When the body isn’t in the canonical string, the payload can be rewritten without the signature noticing.
  • No replay window. A signature without a timestamp means a captured request replays forever.
  • Forgetting timingSafeEqual needs equal lengths. A truncated signature throws instead of returning false — guard the length check.
  • Parsed JSON as the signed body. Re-serializing JSON changes the bytes (key order, whitespace); always sign and verify the raw request body.

See Also

Frequently Asked Questions

What goes into the string I sign?

Whatever the server can rebuild byte-for-byte from the received request: typically method, path, sorted query params, timestamp, and the raw body. The exact canonical form is part of your API contract — document it, because any undocumented field makes client implementations guess.

Why does HMAC need a timestamp?

Signatures prove authenticity, not freshness. Without a timestamp, an attacker who captures one valid request can replay it indefinitely. A 5-minute window plus a nonce gives you both bounds: stale requests die, and identical nonces inside the window collide.

How do constant-time comparisons prevent attacks?

== exits on the first differing byte, so the response time leaks how many leading bytes were right. An attacker measures that leak and reconstructs a valid signature byte by byte. timingSafeEqual-style functions compare every byte in fixed time — just remember they throw on different lengths, so guard that first.

HMAC vs asymmetric signatures (Ed25519): when each?

HMAC when both parties can hold the same secret — server-to-server, your own webhooks. Asymmetric when the signer can't be trusted with a shared credential: a third-party client, a public integration, or anywhere you need non-repudiation (the verifier holds the public key and can't forge).

Is HMAC signing enough without HTTPS?

No. HMAC signs the request — it doesn't encrypt it, so anyone on the path reads the body fine. Signing without TLS gives you integrity with zero confidentiality, and replay protection still depends on your timestamp window.

How do I rotate signing keys without downtime?

Accept both the old and new key for an overlap window — clients flip to the new secret whenever they pick it up, and in-flight requests signed with the old key still verify. Close the window after the overlap (typically an hour), then drop the old key. The KeyRotation class above is that scheme in code.