Utilo

Sign an HTTP request on your machine

What AWS SigV4, Alibaba ACS3, Tencent TC3, OAuth 1.0a, and webhook HMAC actually cover, and how to debug a mismatch without posting the secret.

· 6 min read

A signature error is a bad debugging experience. The server answers with one sentence, something like "the request signature we calculated does not match the signature you provided," and stops. It will not tell you whether the date was in the wrong format, whether you forgot to sign the host header, or whether the body you hashed was not the body you sent. The usual next step is to paste the access key into a website that promises to "calculate SigV4." That website now has the key.

The HTTP signature calculator does the same arithmetic in the tab. This note is the map of what that arithmetic is covering, so a mismatch has a place to look. If the failing call started life as a curl command copied from DevTools, the curl converter is the other half: it shows the method, URL, headers, and body as code, still without uploading them.

What gets signed

A signature is not a hash of "the request" in the vague sense. It is a hash of a string the signer and the server must build the same way. Change one byte of that string and the signature changes, even when a human would say the request is the same.

For AWS Signature Version 4 the string is built in layers:

  1. A canonical request: the method, the normalized path, the normalized query, a sorted list of signed headers with their values, and the hex SHA-256 of the body.
  2. A string to sign: the algorithm name, the timestamp, the credential scope (date/region/service/aws4_request), and the hash of that canonical request.
  3. A signing key derived from the secret access key by a chain of HMACs: date, then region, then service, then the literal aws4_request.
  4. An HMAC of the string to sign under that derived key. The result goes in the Authorization header, next to the access key id, the scope, and the list of signed header names.

Alibaba Cloud ACS3 and Tencent Cloud TC3 are the same shape with different header names and a different scope string. OAuth 1.0a is older and sorts parameters instead of building a canonical request, then HMAC-SHA1s the result. A webhook signature is usually smaller: HMAC of the raw body, sometimes of timestamp + "." + body, compared to a header the provider sent you.

If two implementations disagree, they disagreed about one of those layers. The calculator prints the intermediate strings so you can see which layer moved.

The mistakes that look like a crypto bug

Most mismatches are not a wrong HMAC implementation. Web Crypto and every server library compute HMAC-SHA256 the same way. The inputs differ.

The body hash is of the bytes on the wire. A JSON library that reorders keys, adds a space, or changes \u0026 into & produces a different SHA-256. Pretty-printing the body before you sign, then sending the compact form, fails. Sign the exact bytes you will send. An empty body is still hashed: SigV4 uses the hash of the empty string, which is a well-known constant, not the hash of {} and not an omitted field.

Signed headers are a set, and the set is part of the signature. If you sign host and x-amz-date but the server also expects content-type, the canonical request on the server is longer than yours. The header names are lowercased and sorted. The values are trimmed in a specific way. A header you included in the list but did not send, or sent under a different capitalization that a proxy rewrote, breaks the match. The Authorization header lists the names. Compare that list before you compare the hex.

The clock and the credential scope. x-amz-date is YYYYMMDDTHHMMSSZ, UTC, no colon. The scope uses only the date portion, then the region, then the service (s3, execute-api, and so on). Signing with region us-east-1 and sending the request to an eu-west-1 endpoint is a valid-looking signature for a different scope. The server computes its own scope from the endpoint it received, not from the region you intended.

The path and the query are normalized. Encode the path the way the signer specifies. A query parameter that is not sorted, or a + that one side treats as a space and the other leaves as %2B, changes the canonical request. This is a different problem from Base64. Percent-encoding is about reserved characters in a URI. The signature is about whether both sides applied the same encoding to the same characters.

Temporary credentials need the token header. A key that starts life in STS or in a role session is not enough. The session token is a header (x-amz-security-token on AWS) and it has to be in the signed set. Leaving it out produces a signature that is internally consistent and still rejected.

Webhooks are a smaller version of the same trap

GitHub, Stripe, and Slack do not use SigV4. They send a signature over the raw body so you can tell the delivery from a forged POST. The failure modes are narrower and just as common.

Stripe signs timestamp.body and expects you to reject a timestamp that is too old, because a captured delivery can be replayed. If you hash the body and forget the timestamp prefix, your hex will never match. If your framework already parsed the JSON and you re-serialize it, the bytes changed before you hashed them. Read the raw body.

GitHub's X-Hub-Signature-256 is sha256= plus the hex HMAC of the raw body under the webhook secret. Slack's version has changed across signing-secrets and the older verification token. The calculator is there so you can try the scheme the docs name against the exact payload, on a machine that already has the secret, instead of pasting the secret into a form.

A match means the bytes and the key agree. It does not mean you should skip the timestamp check, and it does not mean the JSON inside the body is a schema you trust.

OAuth 1.0a still shows up

A few older APIs still want OAuth 1.0a HMAC-SHA1. The signature base string is the method, the base URL, and the parameters, percent-encoded and sorted. The nonce and the timestamp are parameters, so they are inside the signature. The signing key is consumer_secret&token_secret, with an empty token secret when there is no token. A library that sorts before encoding, or encodes the space as + instead of %20, will not match a library that does the opposite. Print the base string. The HMAC is rarely the part that is wrong.

Using the two tools together

Copy the failing call as curl. Open the curl converter if you want that call as fetch or Python while you are fixing the client, and to see the headers as a list rather than as backslash-continued lines. Then open the signature calculator and fill the same method, URL, headers, body, region, service, and keys.

Compare the canonical request first, not the final signature. If the canonical request matches an example from the vendor and the signature still does not, the key or the scope is wrong. If the canonical request does not match, stop. The HMAC cannot fix a different string.

Nothing in that workflow needs to leave the browser. The page does not send the request for you. A correct signature is not evidence that the call is authorized to do what it does. It is evidence that both sides built the same string from the same secret.

Related guides