Signed requests: HMAC and OAuth 1.0a
Proving a request wasn't changed on the way, and the encoding details that break signatures.
A bearer token says who you are. It says nothing about the request it came with. Anyone who captures a token can attach it to a different request, with a different body, and the server can't tell. Signed requests close that gap: the client computes a signature over the request itself with a secret key, and the server recomputes it. Change one byte of the request and the signatures no longer match.
You'll meet signing in two places. Webhooks are the common one: GitHub, payment providers and many others sign the requests they send you, so you can prove they came from them. The other is APIs that sign every call, either with their own HMAC scheme or with OAuth 1.0a, which still turns up in older integrations. Both are below, against the HMAC endpoint and the OAuth 1.0a test server.
HMAC signing
HMAC, from RFC 2104, combines a secret key and a message into a fixed-size code. Without the key you can't produce the right code, and the code reveals nothing about the key. This API's rule is typical of webhook schemes:
X-Timestampis the current Unix time in seconds.X-Signatureis the HMAC-SHA256, in hex, of the timestamp, a dot, and the raw request body, using the shared secretapibee-hmac-secret.
From a shell:
BODY='{"event":"ping"}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac apibee-hmac-secret | awk '{print $NF}')
curl -X POST https://api.testingapis.com/api/auth/hmac \
-H "Content-Type: application/json" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG" \
-d "$BODY"
{"authenticated":true,"scheme":"hmac-sha256","timestamp":1791714661,"bodyLength":16}And from Node, which is how you'd write it in a test:
// sign.mjs, part 1: an HMAC-signed request
import { createHmac, randomBytes } from "node:crypto";
const body = JSON.stringify({ event: "ping" });
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = createHmac("sha256", "apibee-hmac-secret").update(`${timestamp}.${body}`).digest("hex");
const hmac = await fetch("https://api.testingapis.com/api/auth/hmac", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Timestamp": timestamp, "X-Signature": signature },
body,
});
console.log(hmac.status, await hmac.text());Putting the timestamp inside the signed message matters. Without it, a captured request could be sent again next week and still verify. With it, the server can refuse old requests, and the attacker can't update the timestamp without breaking the signature.
What breaks a signature
Each row was sent to this API with a signature computed for the original request:
| Change | Response |
|---|---|
The body changed from ping to pong | 401 Signature mismatch. |
| One space added after the colon in the JSON | 401 Signature mismatch., bodyLength 17 instead of 16 |
| A timestamp 400 seconds old, correctly signed | 401 X-Timestamp is 401s away from server time (tolerance 300s). |
| A timestamp 400 seconds in the future | 401, the same message |
| A timestamp 299 seconds old | 200 |
| A timestamp in milliseconds | 401, the message reports a gap of about 1.8 trillion seconds |
| The wrong secret | 401 Signature mismatch. |
The space is the row to remember. Here is the full response for it:
{
"status": 401,
"error": "Unauthorized",
"message": "Signature mismatch.",
"hint": "X-Signature = lowercase hex HMAC-SHA256(secret, X-Timestamp + \".\" + raw request body). Optional 'sha256=' prefix.",
"bodyLength": 17
}The signature covers bytes, not meaning. {"event":"ping"} and {"event": "ping"} are the same JSON and different signatures. The usual bug is a client, or a framework on the server, that parses the body and serialises it again before signing or verifying: key order or spacing changes, and every signature fails. Servers must verify against the raw body as it arrived. When you test a webhook receiver, send a body with unusual spacing and key order, correctly signed, and expect it to pass.
Replays inside the window
The timestamp stops old requests, but on its own it leaves a 5-minute window in which a captured request could be sent again as often as an attacker liked. This server closes it by remembering every signature it accepts until the signature falls outside the window. The same request, with the same timestamp and signature, sent a second time got 401 and This signature was already used (replay refused). Sign each request with a fresh timestamp. Other schemes do the same job with a unique ID in each request. Whichever is used, test it by sending a valid request twice. One leniency remains: an uppercase signature passes, although the docs ask for lowercase. That's harmless, but the docs and the behaviour should agree.
OAuth 1.0a signatures
OAuth 1.0a, defined in RFC 5849, signs every request too, but instead of signing the raw body it builds a normalised description of the request called the signature base string: the method, the URL, and every parameter, each percent-encoded, sorted and joined. Both sides build the same string, so formatting differences don't matter, as long as both build it exactly the same way. That last condition is where nearly every OAuth 1.0a bug lives.
// sign.mjs, part 2: OAuth 1.0a, two-legged (consumer key and secret only), HMAC-SHA1
// RFC 3986 encoding: encodeURIComponent leaves !'()* alone, OAuth needs them encoded
const encode = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
const url = "https://api.testingapis.com/api/auth/oauth1";
const query = { status: "hello world" };
const oauth = {
oauth_consumer_key: "apibee-consumer-key",
oauth_nonce: randomBytes(8).toString("hex"),
oauth_signature_method: "HMAC-SHA1",
oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
oauth_version: "1.0",
};
// Every OAuth and query parameter, encoded, sorted, joined
const params = Object.entries({ ...oauth, ...query })
.map(([k, v]) => [encode(k), encode(v)])
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([k, v]) => `${k}=${v}`)
.join("&");
const baseString = ["GET", encode(url), encode(params)].join("&");
const key = `${encode("apibee-consumer-secret")}&`; // empty token secret: no token
oauth.oauth_signature = createHmac("sha1", key).update(baseString).digest("base64");
const header = "OAuth " + Object.entries(oauth).map(([k, v]) => `${encode(k)}="${encode(v)}"`).join(", ");
const signed = await fetch(`${url}?status=${encode(query.status)}`, { headers: { Authorization: header } });
console.log(baseString);
console.log(signed.status, (await signed.text()).slice(0, 160));200 {"authenticated":true,"scheme":"hmac-sha256","timestamp":1791714660,"bodyLength":16}
GET&https%3A%2F%2Fapi.testingapis.com%2Fapi%2Fauth%2Foauth1&oauth_consumer_key%3Dapibee-consumer-key%26oauth_nonce%3D98130cce1d73d9f0%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1791714661%26oauth_version%3D1.0%26status%3Dhello%2520world
200 {"authenticated":true,"scheme":"oauth1","legs":"two-legged","signatureMethod":"HMAC-SHA1","parametersIn":"Authorization header","consumerKey":"apibee-consumer-kLook at the end of the base string: status%3Dhello%2520world. The space in "hello world" was encoded to %20 as a parameter value, and then the whole parameter string was encoded again, so %20 became %2520. That double encoding is correct and confuses everyone the first time.
Where OAuth 1.0a signing goes wrong
- Using
encodeURIComponentas is. It leaves!,',(,)and*unencoded, and RFC 5849 requires them encoded. The bug only shows when a value contains one of them. - Spaces as
+. Form encoding writes spaces as+; OAuth requires%20. This server reads a+in the query string as a space, as form decoding does, and signs it as%20, so a client must do the same. - Leaving query parameters out of the base string. They're part of the signature.
- Including the port in the URL when it's the default (443 for HTTPS). It must be left out.
- Forgetting the
&at the end of the signing key when there's no token secret:consumer_secret&, notconsumer_secret.
This server is unusually helpful when a signature fails: the 401 for a mismatch includes the base string the server built, so you can compare it with yours character by character. Real providers rarely do that. Errors come back in the oauth_problem format, both in the body and in WWW-Authenticate. A reused nonce, for example:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: OAuth realm="apibee", oauth_problem="nonce_used"
{"status":401,"error":"Unauthorized","message":"This oauth_nonce was already used with this timestamp. Send a new nonce.","oauth_problem":"nonce_used","details":null}Like the HMAC endpoint, OAuth 1.0a refuses the identical request sent twice, here by remembering nonces rather than signatures. A request signed 600 seconds ago fails with timestamp_refused, and the response says which timestamps would have been accepted. Changing the query after signing fails with signature_invalid.
Exercise
Write a small webhook receiver test: sign {"amount":100,"currency":"INR"} for the HMAC endpoint, then send it four ways: as signed; with the keys in a different order; with amount changed to 1; and the original again after 6 minutes. Predict each result before sending. Then sign an OAuth 1.0a request whose status is it's (really) done! with plain encodeURIComponent, and with the encode function above, and compare.