DEV Community

EvvyTools
EvvyTools

Posted on

How HMAC-SHA256 Works for API Request Signing

SHA-256 tells you whether data has changed. HMAC-SHA256 tells you whether data has changed AND who created it. That second property is what makes HMAC the foundation of API request authentication, webhook verification, and secure message passing between services.

Most developers use HMAC-SHA256 regularly through platform integrations without thinking about how the signature is computed. When you need to implement verification yourself, write a custom signing scheme, or debug a signature mismatch, understanding the mechanics helps considerably. And when something breaks, knowing the construction tells you exactly where to look.

What HMAC Actually Is

HMAC stands for Hash-based Message Authentication Code. It wraps a hash function with a secret key in a specific way that gives the output a property a plain hash doesn't have: you can only reproduce it if you know the key.

The construction is defined in RFC 2104, which is worth reading if you want the formal specification. At a high level, HMAC works as follows:

  1. Derive two sub-keys from the secret key (an outer key and an inner key) using XOR with padding constants.
  2. Hash the inner key concatenated with the message.
  3. Hash the outer key concatenated with the result of step 2.

The two-layer construction prevents a class of attacks called length-extension attacks, which affect plain SHA-256. An attacker who knows the SHA-256 hash of a message can compute the SHA-256 hash of the original message plus additional data without knowing the original message. HMAC's construction closes that attack surface entirely.

Server rack with organized cables in a network facility
Photo by Alexas_Fotos on Pixabay

Why You Can't Just SHA-256 a Secret Key With a Message

A common mistake is computing SHA256(secret + message) or SHA256(message + secret) as a substitute for HMAC. Neither is secure.

SHA-256 is vulnerable to length-extension attacks: if you compute SHA256(secret + message), an attacker who knows the hash can compute SHA256(secret + message + extra_data) without knowing the secret. This allows them to forge valid hashes for extended messages.

HMAC-SHA256 avoids this by design. The two-layer construction breaks the algebraic property that makes length-extension attacks possible. This isn't an implementation choice you can match by being clever about how you concatenate inputs. Use a proper HMAC implementation from your language's standard library; don't hand-roll this construction.

The Signing and Verification Pattern

The signing side computes an HMAC over the payload using the shared secret:

signature = HMAC-SHA256(secret_key, message)
Enter fullscreen mode Exit fullscreen mode

This signature is included with the request, typically as a header. Common header names are X-Hub-Signature-256 for GitHub webhooks and Stripe-Signature for Stripe webhooks.

The verification side receives the request, retrieves the shared secret (from a configuration store, never from the request itself), computes the same HMAC, and compares the result to the signature in the header:

expected = HMAC-SHA256(secret_key, request_body)
if constant_time_compare(expected, received_signature):
    proceed
else:
    reject
Enter fullscreen mode Exit fullscreen mode

The comparison must be constant-time. A naive string comparison that short-circuits on the first mismatched character leaks timing information. An attacker can probe different signature values and observe which prefixes match by measuring response time differences. Most languages provide a timing-safe comparison function in their cryptography standard library. Use it.

Platform-Specific Variations

GitHub webhooks: The signature is HMAC-SHA256 of the raw request body, hex-encoded and prefixed with sha256=. The header is X-Hub-Signature-256.

Stripe webhooks: The payload includes a timestamp (t=) and a signature (v1=). The signed data is the timestamp and raw body concatenated: timestamp.body. This construction prevents replay attacks where a valid signature for an old payload is replayed with the same body content.

Shopify webhooks: HMAC-SHA256 of the raw request body, base64-encoded. The header is X-Shopify-Hmac-SHA256.

The variation is in encoding (hex vs base64), payload construction (raw body vs body plus timestamp), and header names. The core cryptographic primitive is the same across all of them.

Language-Specific Implementation

Most programming languages include HMAC support in their standard library. Python uses the hmac module:

import hmac
import hashlib

signature = hmac.new(
    key=secret.encode('utf-8'),
    msg=body.encode('utf-8'),
    digestmod=hashlib.sha256
).hexdigest()
Enter fullscreen mode Exit fullscreen mode

Use hmac.compare_digest rather than == for the verification comparison. It provides constant-time comparison and prevents timing attacks. Passing the wrong type to compare_digest raises a TypeError, which is preferable to silently comparing bytes against a string and getting the wrong result.

Node.js uses the built-in crypto module:

const crypto = require('crypto');
const signature = crypto
  .createHmac('sha256', secret)
  .update(body)
  .digest('hex');
Enter fullscreen mode Exit fullscreen mode

Use crypto.timingSafeEqual for the verification step. Both the expected and actual signature values must be the same byte length before comparing, since timingSafeEqual throws if they differ in length. Convert both to Buffer before passing them.

The important point in both cases: use the HMAC function from the standard library, not a hand-rolled implementation. Standard library implementations have been audited and handle edge cases correctly.

Notebook with handwritten implementation notes open on a desk
Photo by Lighten Up on Pexels

Debugging Signature Mismatches

Signature mismatches during implementation are almost always one of these problems:

Encoding mismatch. You're computing the HMAC on a UTF-8 string but comparing against a value computed on raw bytes, or vice versa. Be consistent about how you encode the message before hashing.

Raw body vs parsed body. Webhook verification requires hashing the raw request body as received, before any JSON parsing. If you hash the JSON output of your request parsing library, it may have different whitespace, different key ordering, or different Unicode normalization than the original body. Express.js, for example, requires enabling raw body capture separately from JSON parsing.

Secret key format. Some platforms provide the key as a hex string, some as plain text, some as base64. Use the raw bytes of the key, not the hex-encoded string representation, as the HMAC key input. Treating a hex string as the key directly produces a different HMAC than treating those same hex characters as a byte array.

Header prefix. GitHub's signature header value is sha256=<hex_digest>. If you're comparing the full header value against a raw hex digest, the comparison will always fail because of the prefix. Strip the prefix before comparing.

For quick testing during implementation, the Hash Generator on EvvyTools computes HMAC-SHA256 with a custom secret key, running entirely in the browser. Enter your test payload, set the secret, and compare the output against your implementation's result. Your test secrets don't go to any server.

Using HMAC for Signed URLs and Time-Limited Tokens

HMAC-SHA256 is also used outside of webhook contexts for signed URLs and short-lived tokens. The pattern is:

  1. Assemble a string containing the data you want to authenticate: a user ID, an expiry timestamp, an action type.
  2. Compute HMAC-SHA256 of that string with your signing key.
  3. Append the signature to the URL or token.

On verification, reconstruct the data string from the URL or token parameters, compute the expected HMAC, and compare using a constant-time comparison function. Including a timestamp in the signed data prevents replay attacks: a signed URL becomes invalid after its expiry timestamp passes.

For the foundational explanation of how SHA-256 differs from earlier algorithms and why it's the current standard for constructions like HMAC, the article on How Cryptographic Hash Functions Work: MD5, SHA-1, SHA-256, and SHA-512 Explained covers the underlying properties that make SHA-256 appropriate here where SHA-1 and MD5 are not.

Top comments (0)