Signing API Requests with HMAC-SHA256

How to sign API requests using HMAC-SHA256. AWS Signature Version 4, canonical request construction, Authorization header, and replay attack prevention.

Signing API Requests with HMAC-SHA256

In machine-to-machine APIs, request signing authenticates the client without sending credentials in plaintext. Instead of transmitting an API secret, the client signs a representation of the request - method, path, headers, timestamp - with HMAC-SHA256 using the secret as the key.1 The server recomputes the signature using its stored copy of the secret. Because only the holder of the secret can produce a valid signature, the server can authenticate the request without the client ever revealing the secret.

AWS Signature Version 4, the most widely implemented API signing scheme, uses HMAC-SHA256 at multiple levels: a derived signing key (HMAC applied four times to the secret, date, region, service, and constant string) signs a canonical request hash.23 Many APIs implement a simpler single-level HMAC over the request body or a canonical string. The pattern is consistent: choose what to sign, agree on a canonical format, and verify with HMAC-SHA256.

What this page covers

  • HTTP method part of the canonical string per the worked example
  • URL path part of the canonical string
  • Timestamp part of the canonical string
  • Request body hash included when the body carries the meaningful payload
  • Specific headers Content-Type, Host, and custom auth headers when they affect request semantics

Opens the Hash Generator with this section's checklist shown at the top of the tool.

Open in the tool →

How request signing works

The client constructs a canonical representation of the request - typically a string joining the HTTP method, URL path, sorted query parameters, selected headers, and a hash of the request body.4 HMAC-SHA256 over this string with the API secret produces the signature. The client includes the signature in an Authorization or X-Signature header. The server reconstructs the same canonical string from the incoming request, computes HMAC-SHA256 with its stored secret, and compares. Consequently, any modification to the signed elements - URL, body, headers - invalidates the signature immediately.

Agreeing on the canonical form first

Write down the exact ordering, escaping, timestamp format, and body-hash rule before either side implements signing. The HMAC math is simple; the hard part is making sure both client and server sign the same byte sequence. A shared canonicalization test with known request examples prevents weeks of intermittent production failures. Without this agreement, the most common bug is a subtle mismatch where the client includes a header the server ignores, or the server decodes a URL segment the client left encoded, and both sides sign different bytes without realizing it.

You prevent weeks of intermittent failures by writing a shared canonicalization test with known request examples that both client and server must pass. The HMAC math is simple, but the hard part is guaranteeing that both sides sign the identical byte sequence every time. CapyToolkit computes HMAC-SHA256 locally so you can confirm the output format before agreeing the canonical string with another team.

Including a timestamp to prevent replay attacks

Signing the request content alone is insufficient if an attacker can capture a valid signed request and re-send it later. Building on this risk: most signing schemes include a timestamp in the signed payload and require the server to reject requests where the timestamp is more than a few minutes old. AWS Signature Version 4 uses an X-Amz-Date header that must be within five minutes of server time. The combination of signature (proves authenticity) and timestamp (bounds freshness) together prevent both forgery and replay.

Keeping clocks close enough to verify signatures

Server time must be reliable enough for the acceptance window you choose. If your servers drift by more than a few minutes, valid requests start failing and teams are tempted to widen the window. Configure time synchronization and monitor clock drift before relying on short replay windows for high-volume APIs. Network Time Protocol with a reliable stratum-1 source keeps most server fleets within a few milliseconds of UTC, which gives you a comfortable margin even with a tight five-minute acceptance window.

Designing a simple signing scheme

For an internal API, a practical signing scheme is: concatenate the HTTP method, request path, ISO-8601 timestamp, and SHA-256 hash of the request body into a canonical string, compute HMAC-SHA256 with the API secret, and include the hex signature plus timestamp in request headers. The server validates the signature and rejects requests older than sixty seconds. Yet even this simple scheme requires careful attention to the canonical format - any ambiguity about which headers or query parameters are included, or the encoding of special characters, creates implementation bugs that produce valid-format but failing signatures.

Limiting the blast radius of signing keys

Issue separate signing keys per client, environment, or integration rather than sharing one global secret. If one key is exposed, you can rotate it without disrupting every integration. Keep the key identifier in the request header so the server can choose the correct secret without leaking timing information about which clients exist. A per-client key also lets you revoke access for a single compromised integration without forcing every other client to update their stored secrets simultaneously, which is especially valuable in microservice architectures where dozens of services share the same API.

When signature verification fails, debug the canonical string first

When HMAC signature verification fails in production, log both the client's canonical string and the server's reconstructed canonical string before comparing digests. The HMAC computation is deterministic: identical canonical strings always produce identical digests for the same key, so hash a canonical string the way SigV4 does and you have a third value to check both sides against. Differences in the canonical string almost always trace to URL encoding (the client percent-encodes the path while the server does not), extra whitespace in header values, or clock skew that pushes the timestamp outside the server's acceptance window.

For AWS Signature Version 4 specifically, the canonical query string requires parameter names and values sorted alphabetically by name, with each value percent-encoded using uppercase hex digits (%2F rather than %2f).5 Lowercase percent-encoding causes a mismatch that is invisible without logging both canonical strings side by side. Log the full string including newlines when debugging; do not trim or abbreviate it.

When to use this

Use request signing when your API must authenticate machine-to-machine requests without exposing a secret in transit. Before wiring up server-side verification, check that your canonical string produces the exact signature you expect. You should implement or require signing for any API endpoint that processes payments, modifies user data, or initiates infrastructure changes.

Examples

Simple HMAC-SHA256 request signing

Before
Method: POST
Path: /api/orders
Timestamp: 2026-05-23T14:00:00Z
Body hash (SHA-256): b94d27b9...
After
Canonical string: "POST\n/api/orders\n2026-05-23T14:00:00Z\nb94d27b9..."
HMAC-SHA256 signature: abc123...
Authorization: HMAC-SHA256 key_id=mykey, sig=abc123, ts=2026-05-23T14:00:00Z

AWS Signature Version 4 - key derivation

Before
Secret: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"
Date: 20260523, Region: us-east-1, Service: s3
After
kDate    = HMAC-SHA256("AWS4" + secret,  "20260523")
kRegion  = HMAC-SHA256(kDate,           "us-east-1")
kService = HMAC-SHA256(kRegion,         "s3")
kSigning = HMAC-SHA256(kService,        "aws4_request")
Signature = HMAC-SHA256(kSigning,       canonicalRequest)

AWS derives a date/region/service-scoped signing key rather than using the raw secret, limiting the blast radius if a derived key is exposed.

Sources
  1. 1.

    H. Krawczyk, M. Bellare, and R. Canetti, "HMAC: Keyed-Hashing for Message Authentication," RFC 2104, IETF, February 1997. https://www.rfc-editor.org/rfc/rfc2104.html

  2. 2.

    "SHA-2," Wikipedia, accessed June 2026. https://en.wikipedia.org/wiki/SHA-2

  3. 3.

    AWS SDK for Go, "v4.go — deriveSigningKey," github.com/aws/aws-sdk-go, accessed June 2026. https://github.com/aws/aws-sdk-go/blob/main/aws/signer/v4/v4.go

  4. 4.

    Cloudflare, "Token Authentication for Cached Private Content and APIs," blog.cloudflare.com, accessed June 2026. https://blog.cloudflare.com/token-authentication-for-cached-private-content-and-apis

  5. 5.

    AWS SDK for Go, "Issue #2969 — CanonicalQueryString sorting before encoding," github.com/aws/aws-sdk-go-v2, accessed June 2026. https://github.com/aws/aws-sdk-go-v2/issues/2969

Verifying Webhook Signatures with HMAC-SHA256

For incoming webhooks, the signature tells your server whether the request came from the expected sender. The provider includes an HMAC-SHA256 signature computed from the request body and a shared secret; your server recomputes the HMAC with the same secret and compares the two values.1 A match confirms the payload arrived intact and originated from the sender - not from an attacker forging the request. That order matters.

The verification pattern is the same across providers: receive the raw HTTP body bytes before parsing, retrieve the signature header, compute HMAC-SHA256 with your shared secret, and compare with a timing-safe equality function.2 Stripe warns that any framework manipulation of the raw body causes verification to fail; JSON parsing can change whitespace or key order enough to produce a different byte sequence.3

What this page covers

  • GitHub header X-Hub-Signature-256, prefixed with sha256=
  • Stripe header Stripe-Signature, includes a timestamp for replay protection
  • Timing-safe comparison use compare_digest (Python) or crypto.timingSafeEqual (Node), never ==
  • Raw body requirement verify before any JSON parsing; re-serialization changes whitespace/key order and breaks the signature

Opens the Hash Generator with this section's checklist shown at the top of the tool.

Open in the tool →

How webhook signature verification works

HMAC-SHA256 combines a secret key with message content in a standardized way defined by RFC 2104.1 GitHub computes the hash signature from your webhook secret token and payload contents, sends it in the X-Hub-Signature-256 header, and prefixes the value with sha256=.2 Stripe signs events with the Stripe-Signature header and requires the raw request body string; framework changes such as whitespace edits, key reordering, JSON conversion, or encoding changes cause verification to fail.3 Consequently, your server must preserve the exact body bytes from the network layer through to the comparison, without parsing or re-serialization in between. Keep the verifier close to the network boundary, where the original bytes and headers remain observable before your framework rewrites them.

Building the expected signature before business logic

Do the signature check before dispatching work to your application layer. If the signature fails, return an authentication error and avoid database writes, queue jobs, or notifications. This ordering keeps forged requests from creating partial side effects even when they reach the edge of your service. Moving the verification step as close to the HTTP handler as possible means the raw body bytes and headers are still in their original form, which avoids the subtle bugs that happen when middleware rewrites the request before your verifier sees it.

You limit the blast radius of a bad request by rejecting it at the edge before it reaches a database write, a queued job, or a notification. A forged webhook that fails verification should never create a side effect, because doing so lets an attacker trigger actions without a valid signature. CapyToolkit's Hash Generator can inspect HMAC-SHA256 output locally while you build the verifier that enforces this ordering.

Security considerations

Timing attacks are a real risk in HMAC verification. Python's hmac documentation warns that comparing a digest with an externally supplied value using == can increase timing-attack exposure, and recommends compare_digest for cryptographic verification.4 A timing-safe comparison keeps verification focused on whether the complete expected value matches, rather than exposing partial-match timing through ordinary equality checks.

Handling failed verification without leaking details

Return the same generic response for missing headers, malformed signatures, clock drift, and HMAC mismatches. Detailed errors can help an attacker learn which part of the protocol is wrong. Log the provider, event ID, and signature header name internally, but keep the client-facing response short and consistent. A single HTTP 401 response with no body tells the caller nothing about whether the key was wrong, the header was missing, or the timestamp was stale, which forces an attacker to guess blindly rather than iterate against a specific failure mode.

Provider-specific patterns

Provider protocols differ around the same HMAC-SHA256 core. Stripe includes a timestamp in the Stripe-Signature header and uses that timestamp as part of replay protection, so applications should reject timestamps outside the accepted window instead of accepting any valid signature.5 Verify the exact header name, signature format, timestamp rules, and signed payload construction in each provider's documentation before implementing.

Testing provider-specific examples before deployment

Run your verifier against sample payloads from the provider documentation before accepting live traffic, which is the cheapest way to confirm a sample payload hashes to Stripe's signature rather than adding temporary debug logging to a webhook handler. Stripe and GitHub format their signatures differently, so a helper that works for one provider may fail the other even though both use HMAC-SHA256. Provider examples give you a safe baseline before you add production logging and alerting. Write automated tests that replay these documented examples on every CI run, so a future refactor that accidentally changes the signed payload construction fails immediately rather than silently breaking webhook processing in production.

For language-specific framework integrations, raw body access differs

Framework integrations differ in how they expose raw request bytes to your application code, and the differences matter because even a single whitespace change in the serialized payload invalidates the HMAC signature. The important rule is to attach raw-body middleware before JSON parsing, or read the raw request body directly before any parser rewrites it. If the framework has already parsed and re-serialized the payload, your code computes HMAC over new bytes and verification fails. In Django, for example, you read the raw body from request.body before accessing request.POST; in Rails, request.body.read gives you the original bytes before the params parser runs.

Document which middleware must run first and add a test that fails if JSON parsing happens before verification. The route contract should name the raw-body middleware, the header used for the signature, and the exact comparison function. That checklist prevents future framework changes from quietly breaking webhook authentication, and it gives new team members a clear ordering constraint to follow when they add middleware to the stack.

When to use this

Verify every incoming webhook before processing its payload. You should implement signature verification for any webhook that triggers state changes - order fulfillment, payment processing, deployment triggers, or user account modifications - where acting on a forged request would cause harm.

Examples

GitHub webhook verification (Node.js)

Before
Request body (raw Buffer): {"action":"opened","number":42,...}
X-Hub-Signature-256 header: sha256=abc123...
After
Computed: sha256=abc123...
crypto.timingSafeEqual passes → payload is authentic.

See the HMAC-SHA256 in Node.js guide for the complete Express middleware implementation.

Stripe webhook verification

Before
Stripe-Signature header: t=1683000000,v1=abc123...
Raw body: {"id":"evt_...","type":"payment_intent.succeeded",...}
After
Signed payload: "1683000000.{raw_body}"
Computed HMAC matches v1 value → event is genuine.

Stripe's SDK handles header parsing and HMAC computation automatically via stripe.webhooks.constructEvent().

Sources
  1. 1.

    H. Krawczyk, M. Bellare, and R. Canetti, "HMAC: Keyed-Hashing for Message Authentication," RFC 2104, IETF, February 1997. https://datatracker.ietf.org/doc/html/rfc2104

  2. 2.

    GitHub, “Validating webhook deliveries,” GitHub Docs, accessed June 2026. https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries

  3. 3.

    Stripe, “Resolve webhook signature verification errors,” Stripe Documentation, accessed June 2026. https://docs.stripe.com/webhooks/signature

  4. 4.

    Python Software Foundation, “hmac - Keyed-Hashing for Message Authentication,” Python 3.10 documentation, March 2026. https://docs.python.org/3.10/library/hmac.html

  5. 5.

    Stripe, “Receive Stripe events in your webhook endpoint,” Stripe Documentation, accessed June 2026. https://docs.stripe.com/webhooks#verify-webhook-signatures-with-official-libraries

FAQ

JSON.parse re-serializes the body, which can change whitespace, key ordering, or number formatting. Even a single extra space produces a completely different HMAC. Always verify the raw body bytes - in Express, use express.raw({ type: "application/json" }) before any JSON parsing middleware.

Store it as an environment variable, never in source code. Use secrets management like AWS Secrets Manager, HashiCorp Vault, or a .env file that is excluded from version control. Rotate the secret if it is ever exposed.

A timing attack exploits the fact that string comparison returns faster when the first character differs, allowing an attacker to guess the expected HMAC value one character at a time by measuring response times. Constant-time comparison functions prevent this by always taking the same time regardless of how many characters match.

Yes, for providers that include a timestamp (Stripe does; GitHub does not). Validate that the timestamp is within a short window - typically 5 minutes - of the current time. This prevents replay attacks where an attacker captures a genuine webhook and re-sends it later.

Yes. Most providers offer CLI tools for local testing: the GitHub CLI can forward webhooks to localhost, and the Stripe CLI has stripe listen -forward-to localhost:3000. These tools generate real HMAC signatures so your local verification code is exercised correctly. CapyToolkit's Hash Generator can also help you inspect HMAC-SHA256 output locally while you build the verifier.

FAQ

An API key is transmitted with every request - any network observer or log can capture it. A signed request never transmits the secret; only the signature travels over the network. Even if an attacker captures a signed request, they cannot extract the secret from the signature, and replay attacks are blocked by the timestamp.

At minimum: HTTP method, URL path, timestamp. Include the request body hash when the body carries the meaningful payload. Include specific headers (Content-Type, Host, custom auth headers) when they affect the semantics of the request. Sort query parameters alphabetically to ensure a deterministic canonical form.

Log both the client's canonical string and the server's reconstructed canonical string before computing HMAC. The HMAC computation is deterministic - if the canonical strings match, the signatures match. Mismatches almost always trace to encoding differences (URL encoding, whitespace, charset), missing headers, or clock skew.

OAuth 1.0 used HMAC-SHA1 for request signing. OAuth 2.0 dropped message signing entirely in favor of bearer tokens (HTTPS for transport security). HMAC-SHA256 request signing is a separate pattern used in AWS, Shopify, and many custom APIs - it is not part of the OAuth 2.0 specification.

No. CapyToolkit's Hash Generator computes HMAC-SHA256 locally in your browser, so an API secret or message you enter is not uploaded to produce a test signature. Use only sample secrets in public environments and rotate any real key that may have been exposed.