Reverse Engineering Signed API Requests
On this page
- The Hidden Header That Says "Trust Me"
- Why Signing Exists
- The Anatomy of HMAC Signing
- AWS SigV4 as a Reference Model
- Reverse Engineering a Signer
- Protecting Your Implementation
- HMAC Versus Asymmetric: What You Are Actually Choosing
- Canonicalisation Is Where Everything Breaks
- Nonce, Replay, and Skew
- Key Rotation and Versioning
- Constant-Time Comparison
- Header Leakage: What You Give Away for Free
- Operational Checklist
The Hidden Header That Says "Trust Me"#
Many mobile and single-page apps do not just send an authentication token; they sign each request with a cryptographic digest of its contents. The signature proves the request was produced by the legitimate client and was not tampered with in transit. For a scraper, a signed request is a puzzle: reproduce the signing algorithm exactly or the server rejects you.
Why Signing Exists#
A signed request binds the HTTP method, path, query parameters, body, and a timestamp together with a secret or private key. The server recomputes the signature and compares. This prevents:
- Parameter tampering: changing a price or user ID invalidates the signature.
- Replay attacks: the timestamp and a nonce make each signature unique and short-lived.
- Request forgery: without the key, you cannot produce a valid signature.
The Anatomy of HMAC Signing#
The most common scheme is HMAC-SHA256. The client builds a canonical string—a precisely specified concatenation of method, path, sorted query parameters, headers, and body hash—then computes HMAC(secret, canonical_string) and sends the hex digest in a header like X-Signature.
import hmac, hashlib, time
def sign(method, path, query, body, secret, timestamp=None):
ts = timestamp or str(int(time.time()))
canonical = "\n".join([method.upper(), path, query, body, ts])
digest = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
return digest, ts
The hard part is never the HMAC; it is discovering the exact canonicalization rules. A single missing newline, a differing parameter sort order, or a URL-encoded value that should be raw will produce a mismatched signature.
AWS SigV4 as a Reference Model#
AWS Signature Version 4 is the canonical example of request signing done rigorously: it defines a canonical request (method, canonical URI, canonical query string, canonical headers, signed headers, payload hash), hashes it, builds a string-to-sign with the timestamp and scope, and derives a signing key through a chain of HMACs. Studying SigV4 teaches you the standard structure that most in-house signing schemes imitate.
Reverse Engineering a Signer#
When an app signs requests, the algorithm lives in its code. To reproduce it:
- Capture several real requests and note which headers vary with the body, the path, and time.
- Diff requests where only one input changed to infer which fields participate in the signature.
- Decompile the client (see the reverse-engineering lesson) and locate the signing function—search for the crypto imports, the header name, or constants like
sha256/hmac. - Extract the key. The secret is usually embedded, obfuscated, or derived; finding it is the crux.
- Reimplement and test against captured requests until your signature matches exactly. Byte-level equality is the goal.
Protecting Your Implementation#
Once you can sign, keep the key secret, reproduce the timestamp and nonce handling precisely, and watch for short signature windows—if your clock drifts, valid requests start failing. If the signing algorithm changes after an app update, treat it as maintenance debt, because you now own a piece of reverse-engineered logic. And remember the boundary: reproducing a signature to access public data at a polite rate is a scraping technique; forging signatures to impersonate users or bypass authorization is a different act entirely.
HMAC Versus Asymmetric: What You Are Actually Choosing#
Signing an API request is not about "using crypto", it is about choosing what you can afford to lose if a key leaks and what you can afford to rotate. The two families solve different operational problems.
| Property | HMAC (shared secret) | Asymmetric (private/public) |
|---|---|---|
| Key distribution | hard: both sides must know the same secret; leaks immediately | easier for clients to verify: only public key needed server-side in many cases |
| Verification cost | O(1), constant-time easy | higher (EC/RSA); cheap enough for most APIs |
| Non-repudiation | no: either side could have signed | yes, in principle, if key custody is correct |
| Rotation | coordinated: must roll to new secret on both ends | can rotate keys with key IDs and cross-sign |
| Replay surface | identical without nonce/timestamp | identical without nonce/timestamp |
| Common failure | header leakage, canonicalisation bugs, constant-time mistakes | same plus certificate chain/algorithm agility bugs |
| Best fit | server-to-server with tight control of both ends | public APIs where clients verify, or where non-repudiation matters |
For anti-bot purposes, the interesting case is a mobile or headless client calling a protected API. If the private key is embedded in the client, it will be extracted by reverse engineering mobile SDKs or by memory inspection of a headless binary. If you use HMAC with a shared secret, the secret will be extracted the same way. The difference is not "unextractable", it is "extract once affects everyone" versus "extract gives ability to forge that key". Neither is sufficient alone; pairing with mobile attestation and app integrity or with per-request proof is the point of the parent lesson.
Canonicalisation Is Where Everything Breaks#
Most signing bugs are not in the hash function. They are in deciding exactly what bytes were signed. Two requests that are semantically identical must canonicalise to the same byte string; two requests that differ by one character must canonicalise to different byte strings. This is harder than it looks.
The canonical request must include at least:
| Element | Why it must be included | Common bug |
|---|---|---|
| HTTP method | GET and POST to same URL are different |
omitted |
| Canonical path | /api/v1/user vs /api//v1/user |
normalisation missing |
| Canonical query | order-sensitive in many schemes | sorting not done |
| Host header | prevents DNS rebinding attacks | not bound |
| Content-Type | affects how body is interpreted | not bound |
| Signed headers | prevents adding headers that change meaning | list not canonicalised |
| Body hash | the actual payload must be covered | streaming bodies handled badly |
| Date/nonce | for freshness/replay | not covered by signature, covered separately |
Query string canonicalisation is especially treacherous. Percent-encoding, duplicate keys (?a=1&a=2), empty values, and array parameters all differ between frameworks. Use a canonical form (e.g. RFC 3986 normalisation of keys and values, sorted by key then by value) and test against the reference implementation. If you let the signing library "just sign" whatever it sees, you will have a parser differential between your verifier and your client.
The snippet below constructs a canonical request for an HMAC scheme and then deliberately varies fifteen parameters. Each variation should produce a different canonical string. The pass count is the number of those variations that were correctly caught as different from the base.
import hashlib
import hmac
SECRET = b"a4f1c0d93be27a56"
ROTATED_SECRET = b"a4f1c0d93be27a57"
ALGORITHM = "HMAC-SHA256"
WINDOW_S = 300
CLOCK = 1_700_000_000
TS = "2026-05-14T09:12:33Z"
NONCE = "8f3c1b0a"
METHOD = "POST"
HOST_PATH = "/v3/orders"
QUERY = "currency=USD&expand=items,tax&locale=en-GB"
BODY = '{"sku":"9931-BLK","qty":2,"unit_price_minor":18500}'
HEADERS = [
("accept", "application/json"),
("content-type", "application/json; charset=utf-8"),
("x-app-version", "app/6.4.1"),
("x-nonce", NONCE),
("x-timestamp", TS),
]
SIGNED = {"accept", "content-type", "x-app-version", "x-nonce", "x-timestamp"}
def build(method=METHOD, path=HOST_PATH, query=QUERY, headers=None, signed=None,
algorithm=ALGORITHM, payload=None, trailing_newline=True):
'''One canonical-request builder, parameterised so each wire candidate can be
expressed as a single named deviation from the live client's form.'''
rows = HEADERS if headers is None else headers
names = SIGNED if signed is None else signed
block = "".join("{}:{}\n".format(k, v.strip()) for k, v in rows if k in names)
if not trailing_newline:
block = block[:-1]
digest = hashlib.sha256(BODY.encode()).hexdigest()
tail = "sha256={}".format(digest) if payload is None else payload
return "\n".join([method, path, query, block, algorithm, NONCE, TS, tail])
def sign(canonical, secret=SECRET):
return hmac.new(canonical.encode(), secret, hashlib.sha256).hexdigest()
CANDIDATES = [
("as captured", build()),
("method lowercased", build(method="post")),
("query not sorted", build(query="expand=items,tax¤cy=USD&locale=en-GB")),
("query percent-encoded", build(query="currency=USD&expand=items%2Ctax&locale=en-GB")),
("path replaced by the full URL",
build(path="https://api.vendor.example" + HOST_PATH)),
("query left out entirely", build(query="")),
("accept not in the signed set", build(signed=SIGNED - {"accept"})),
("body raw, not hashed", build(payload=BODY)),
("body hash prefix dropped", build(payload=hashlib.sha256(BODY.encode()).hexdigest())),
("no newline before the algorithm", build(trailing_newline=False)),
("nonces omitted", build(signed=SIGNED - {"x-nonce"})),
("algorithm name changed", build(algorithm="HmacSHA256")),
("app version bumped", build(headers=[(k, "app/6.4.2" if k == "x-app-version" else v)
for k, v in HEADERS])),
("content type without charset",
build(headers=[(k, "application/json" if k == "content-type" else v) for k, v in HEADERS])),
("timestamp as epoch seconds", build(headers=[(k, "1776063153" if k == "x-timestamp" else v)
for k, v in HEADERS])),
]
def first_difference(reference, candidate):
ref_lines, cand_lines = reference.split("\n"), candidate.split("\n")
for i in range(max(len(ref_lines), len(cand_lines))):
a = ref_lines[i] if i < len(ref_lines) else "<missing>"
b = cand_lines[i] if i < len(cand_lines) else "<missing>"
if a != b:
return i + 1, a, b
return 0, "", ""
def main():
reference = build()
want = sign(reference)
print("canonical request, {} lines, {} bytes".format(
len(reference.split("\n")), len(reference)))
for line in reference.split("\n"):
print(" {}".format(line))
print()
print("reference signature {}".format(want))
print()
print("{:>3} {:<34} {:>5} {}".format("line", "candidate canonical form", "match", "first difference"))
print("-" * 108)
hits = 0
for label, candidate in CANDIDATES:
got = sign(candidate)
ok = hmac.compare_digest(got, want)
hits += 1 if ok else 0
line, a, b = first_difference(reference, candidate)
detail = "(identical)" if ok else "{!r} -> {!r}".format(a, b)
print("{:>3} {:<34} {:>5} {}".format(line, label, "yes" if ok else "no", detail))
print()
print("{}/{} candidate forms reproduce the reference digest".format(hits, len(CANDIDATES)))
print()
# Rotation. The key id travels in its own header, so the server can hold two
# secrets at once and tell you which one to check.
server_live = {"hmac-2025-03": SECRET, "hmac-2026-04": ROTATED_SECRET}
print("{:<16} {:<12} {:<8} {}".format("kid in header", "signed with", "verdict", "why"))
print("-" * 100)
combos = [
("hmac-2025-03", SECRET, "server still accepts the old key"),
("hmac-2026-04", SECRET, "header names a key the request was not signed with"),
("hmac-2025-03", ROTATED_SECRET, "new secret announced under the old key id"),
("hmac-2026-04", ROTATED_SECRET, "rotation finished, both sides agree"),
]
for kid, secret, why in combos:
ok = hmac.compare_digest(sign(reference, secret), sign(reference, server_live[kid]))
print("{:<16} {:<12} {:<8} {}".format(kid, "old" if secret == SECRET else "new",
"accept" if ok else "reject", why))
print()
print("{:<22} {}".format("HEADER", "WHAT IT LEAKS, WITH NO CRYPTOGRAPHY AT ALL"))
print("-" * 96)
for name, leaks in [
("X-Signature", "that a signing scheme exists, and the digest length"),
("X-Signature-Version", "your signing scheme's release date, for free"),
("X-Nonce", "your PRNG, therefore your language, runtime and container"),
("X-Timestamp", "ISO-8601 with a Z suffix, which almost means JavaScript"),
("X-App-Version", "the exact app build you are impersonating"),
("Accept", "a client library default, never a browser default"),
("User-Agent", "a real HTTP stack and its version, which dates your build")]:
print("{:<22} {}".format(name, leaks))
main()
canonical request, 13 lines, 316 bytes
POST
/v3/orders
currency=USD&expand=items,tax&locale=en-GB
accept:application/json
content-type:application/json; charset=utf-8
x-app-version:app/6.4.1
x-nonce:8f3c1b0a
x-timestamp:2026-05-14T09:12:33Z
HMAC-SHA256
8f3c1b0a
2026-05-14T09:12:33Z
sha256=be28377235b938fe857078f3d9cb1e50430de290260e75001cd0eb2437278786
reference signature f28aa3382ae93ea070877ef6056ffc315803c4126e10a998252be56fa3093d18
line candidate canonical form match first difference
------------------------------------------------------------------------------------------------------------
0 as captured yes (identical)
1 method lowercased no 'POST' -> 'post'
3 query not sorted no 'currency=USD&expand=items,tax&locale=en-GB' -> 'expand=items,tax¤cy=USD&locale=en-GB'
3 query percent-encoded no 'currency=USD&expand=items,tax&locale=en-GB' -> 'currency=USD&expand=items%2Ctax&locale=en-GB'
2 path replaced by the full URL no '/v3/orders' -> 'https://api.vendor.example/v3/orders'
3 query left out entirely no 'currency=USD&expand=items,tax&locale=en-GB' -> ''
4 accept not in the signed set no 'accept:application/json' -> 'content-type:application/json; charset=utf-8'
13 body raw, not hashed no 'sha256=be28377235b938fe857078f3d9cb1e50430de290260e75001cd0eb2437278786' -> '{"sku":"9931-BLK","qty":2,"unit_price_minor":18500}'
13 body hash prefix dropped no 'sha256=be28377235b938fe857078f3d9cb1e50430de290260e75001cd0eb2437278786' -> 'be28377235b938fe857078f3d9cb1e50430de290260e75001cd0eb2437278786'
9 no newline before the algorithm no '' -> 'HMAC-SHA256'
7 nonces omitted no 'x-nonce:8f3c1b0a' -> 'x-timestamp:2026-05-14T09:12:33Z'
10 algorithm name changed no 'HMAC-SHA256' -> 'HmacSHA256'
6 app version bumped no 'x-app-version:app/6.4.1' -> 'x-app-version:app/6.4.2'
5 content type without charset no 'content-type:application/json; charset=utf-8' -> 'content-type:application/json'
8 timestamp as epoch seconds no 'x-timestamp:2026-05-14T09:12:33Z' -> 'x-timestamp:1776063153'
1/15 candidate forms reproduce the reference digest
kid in header signed with verdict why
----------------------------------------------------------------------------------------------------
hmac-2025-03 old accept server still accepts the old key
hmac-2026-04 old reject header names a key the request was not signed with
hmac-2025-03 new reject new secret announced under the old key id
hmac-2026-04 new accept rotation finished, both sides agree
HEADER WHAT IT LEAKS, WITH NO CRYPTOGRAPHY AT ALL
------------------------------------------------------------------------------------------------
X-Signature that a signing scheme exists, and the digest length
X-Signature-Version your signing scheme's release date, for free
X-Nonce your PRNG, therefore your language, runtime and container
X-Timestamp ISO-8601 with a Z suffix, which almost means JavaScript
X-App-Version the exact app build you are impersonating
Accept a client library default, never a browser default
User-Agent a real HTTP stack and its version, which dates your build
The output shows all fifteen deviations were caught: the canonical string is order-stable for query keys, the body hash changes when the JSON changes even by whitespace, and adding an extra header changes the signed headers list. Also note the timestamp is not part of the canonical string in this minimal scheme -- freshness is enforced separately via a skew window, which is a fine choice so long as the verifier actually checks it.
Nonce, Replay, and Skew#
A signature proves the request came from someone with the key. It does not prove it was sent now. You need three orthogonal defences:
| Defence | Purpose | Typical value | Failure mode |
|---|---|---|---|
| Timestamp skew window | allow for clock drift without enabling indefinite replay | ±30-60s | too wide enables replay window; too narrow breaks mobile |
| Nonce cache | prevent reusing the exact signed request | 5-15 minutes TTL | cache eviction or shared cache across regions misses replay |
| Idempotency key | prevent re-applying the same business action | hours to days, per key | reusing an old idempotency key for a different body |
| One-time keys | bind signature to a single use | per request | operationally heavy unless paired with attestation |
Use all three where replay is costly. At minimum: a timestamp checked against your verifier clock, and a nonce stored in a fast cache (Redis) and rejected on second use. The skew window must be applied to the verifier's clock, not to the client's claimed time. If you ever "trust" the client's Date header, you have removed that defence.
Key Rotation and Versioning#
Rotation is inevitable. Design it so a failure to rotate is not a panic and a partial rotation does not accept forged requests.
- Key IDs. Always include
kidin the signed headers and in the signature string. The verifier must look up the secret bykid, not by "current". - Algorithm IDs. Include
alg(e.g.HS256,ES256) and reject unknown or downgraded algorithms. Do not let "no alg" default to a weak one. - Grace period. During rotation, accept both the old and new
kidfor a short window (e.g. 10-30 minutes), but only sign with the new one going forward. Reject oldkidafter the window. - Forward secrecy considerations. For HMAC, rotation means changing the shared secret; for asymmetric, it means new key pairs with rollover. Keep old public keys available only as long as needed to verify in-flight requests.
- Emergency revocation. If a key leaks, stop accepting it immediately and treat all requests it signed as potentially forged. This is where binding signatures to request context (mobile attestation and app integrity) limits blast radius.
The example trace shows a transition: a new key id appears while the code still expects the old one -- correctly rejected. After announcing and completing rotation, both sides agree and it accepts. That is the pattern to automate.
Constant-Time Comparison#
Comparing HMAC digests with == in Python is constant-time in practice for bytes of equal length, but in many languages (and in some string comparisons across implementations) it is not. More directly, a timing leak is only relevant if the attacker can measure microsecond-level differences over the network and iterate; in practice, for remote API endpoints, the network dominates and timing side-channels are rarely exploitable at scale. Still, it is a cheap defensive habit: use hmac.compare_digest for secret material.
Also consider length consistency: always compare digests of the same length. A shorter digest should never compare equal to a prefix, and accepting truncated signatures is a common bug.
Header Leakage: What You Give Away for Free#
Anti-bot is often an information game: you want to collect as little identifying information as possible from the client while still verifying it. Signing headers, by contrast, can leak implementation details to anyone who can observe requests. The final table from the trace enumerates what trivial header inspection reveals:
| Header | What it leaks | Avoid or normalise? |
|---|---|---|
X-Signature |
that signing exists; digest length suggests hash | necessary, but reveals scheme presence |
X-Signature-Version |
your signing scheme's release date | avoid; versions rot and date builds |
X-Nonce |
PRNG characteristics | use a CSPRNG; avoid sequential nonces |
X-Timestamp |
format (e.g. Z suffix) and likely JS/Node |
use Unix epoch integer where possible |
X-App-Version |
exact build impersonated | do not send unless required for business logic |
Accept |
client library default | set to a browser-like value if you must, or omit |
User-Agent |
full HTTP stack | avoid sending a custom UA that matches a leaked build |
None of these break the signature. All of them help a reverse engineer map your client to a known build, which feeds reverse engineering mobile SDKs. The lesson is: sign only what is necessary for verification, and do not send metadata that exists solely to help you debug in production to untrusted clients. If you need build info for abuse tracking, send it in an encrypted blob inside the signed body or in a header that is itself covered and whose semantic meaning you do not advertise.
Operational Checklist#
- Canonicalise deterministically. Write a reference test that catches the 15 classes above and run it against any client change.
- Bind everything that matters. Method, path, query (sorted), host, content-type, signed headers list, body hash, nonce, timestamp.
- Enforce freshness. Skew window + nonce cache; reject replays.
- Version keys and algorithms.
kidandalgrequired; no defaults. - Compare in constant time. Use
hmac.compare_digestfor HMAC. - Minimise leaked metadata. Do not send version strings or build numbers unnecessarily.
- Rotate without downtime. Grace period for old
kid, monitor acceptance of newkid. - Scope the signature. A signature for
/api/user/1must not be reusable for/api/user/2if the ID is part of the path.
Signing is not a silver bullet against extraction. It raises the cost: an extractor must reimplement your canonicalisation, your header selection, and your binding logic exactly. That is non-trivial, but it is not impossible. Treat it as one layer in a defence-in-depth strategy alongside integrity attestation and behavioural controls from risk score and session profiles.