HMAC request signing proves two things to your backend: the caller knows a shared secret, and the request was not altered after it was signed.
It is not encryption — the body stays readable — but it is a strong, lightweight integrity and authenticity check for mobile APIs.
Note: This walkthrough uses a React Native client and
react-native-quick-crypto. The same ideas apply to web or any HTTP client.
Table of contents
- See it in action
- What is HMAC?
- Generate a strong secret
- The canonical string
- Reference implementation
- How to call
signOutgoingRequest - Sort the body before hashing
- Challenges I faced
- Best practice: mirror the backend
- Checklist
- Mental model
See it in action
Before the code — here’s the whole story visually.
Interactive animation
Valid signature — request accepted
Invalid signature — hacker tampering rejected
What is HMAC?
HMAC (Hash-based Message Authentication Code) mixes a hash function (usually SHA-256) with a secret key.
You pass in:
- A message string
- A secret both client and server share
You get back a fixed-length signature (typically hex).
| Property | Meaning |
|---|---|
| Integrity | Change one character of the message → signature changes |
| Authenticity | Only holders of the secret can produce a valid signature |
| Not encryption | Payload remains readable; HMAC proves who signed it |
API flow:
Client → build canonical string → HMAC-SHA256 → send signature header
Server → rebuild same canonical string → HMAC-SHA256 → compare (constant-time)
Same canonical string + same secret = match. Anything else fails.
Generate a strong secret
openssl rand -hex 32
That yields 32 random bytes as 64 hex characters — a 256-bit key that fits HMAC-SHA256 well.
Why bother?
| Weak choice | Problem |
|---|---|
"mySecret123" / app-name keys |
Easy to guess or brute-force |
| Short / human-picked secrets | Low entropy |
| Secrets in git or logs | Instant leak |
Tip: Use a CSPRNG (
openssl rand), store the value in a secrets manager, and never commit or log it.
The canonical string
HMAC does not understand HTTP. You pick the fields that matter, join them in a fixed order, then sign that string.
METHOD
PATH
TIMESTAMP
CLIENT_PLATFORM
SECRET_ID
SIGNED_HEADERS
BODY_HASH
Example:
POST
/api/v1/orders
1710000000000
ios
mobile-app-v1
accept:application/json;x-api-version:1
a3f2c9... ← sha256 hex of the body
More fields → stronger binding. Each line ties the signature to another part of the request:
| Field | Guards against |
|---|---|
| Method + path | Replaying a body on a different endpoint |
| Timestamp | Stale / replayed requests |
| Client platform | Cross-client misuse of the same secret |
| Secret id | Ambiguity during key rotation |
| Signed headers | Spoofing headers you care about |
Body hash (bodyHex) |
Tampering with the payload |
Warning: Signing only
method + pathis weak. Bind method, path, time, identity, headers, and body hash — and keep that list identical on client and server.
Reference implementation
Uses react-native-quick-crypto. Names are illustrative — align them with your backend contract.
import Crypto from "react-native-quick-crypto";
import { resolvePath, sortBodyKeys } from "./signingHelpers";
/** HMAC-SHA256 → hex digest */
export const createHmacDigest = (message, sharedSecret) => {
if (!sharedSecret) {
throw new Error("Shared signing secret is required");
}
const hmac = Crypto.createHmac("sha256", sharedSecret);
hmac.update(message);
return hmac.digest("hex");
};
const sha256Hex = (data) =>
Crypto.createHash("sha256").update(data).digest("hex");
/** Build canonical string and sign it */
export const buildRequestSignature = ({
method,
url,
issuedAt,
body,
sharedSecret,
secretId,
signedHeaderBlock,
clientRuntime, // e.g. "ios" | "android" | "web"
}) => {
let bodyStr = "";
if (body != null) {
bodyStr = typeof body === "string" ? body : JSON.stringify(body);
}
const bodyHex = sha256Hex(bodyStr);
const path = resolvePath(url);
// Order must match the backend exactly.
// More lines = signature covers more of the request.
const canonical = [
method.toUpperCase(),
path,
issuedAt,
clientRuntime,
secretId,
signedHeaderBlock,
bodyHex,
].join("\n");
return createHmacDigest(canonical, sharedSecret);
};
/** Attach time + signature headers to an outgoing request */
export const signOutgoingRequest = (request, sharedSecret) => {
if (!sharedSecret) {
return request;
}
try {
const issuedAt = String(Date.now());
// Sort body keys so client/server stringify & hash the same JSON.
const sortedBody = sortBodyKeys(request.data);
if (sortedBody != null && typeof sortedBody === "object") {
request.data = sortedBody;
}
const signature = buildRequestSignature({
method: request?.method,
url: request.url,
issuedAt,
body: sortedBody,
sharedSecret,
secretId: "mobile-app-v1",
signedHeaderBlock: "accept:application/json;x-api-version:1",
clientRuntime: "ios", // or detect at runtime
});
request.headers["x-request-time"] = issuedAt;
request.headers["x-request-signature"] = signature;
} catch (error) {
console.error("[signing] Failed to sign request:", error);
}
return request;
};
| Field | Role |
|---|---|
secretId |
"mobile-app-v1" — server looks up which secret to use (rotation) |
signedHeaderBlock |
Headers bound into the signature, same format on both sides |
bodyHex |
sha256Hex(bodyStr) — hash of the sorted, stringified body |
How to call signOutgoingRequest
Hook it into your HTTP client request interceptor after headers and body are ready:
import axios from "axios";
import { signOutgoingRequest } from "./requestSigning";
const api = axios.create({
baseURL: "https://api.example.com",
});
api.interceptors.request.use(
(request) => {
request.headers = request.headers ?? {};
request.headers["Content-Type"] = "application/json";
signOutgoingRequest(request, sharedSecret); // adds x-request-time + x-request-signature
return request;
},
(error) => Promise.reject(error),
);
await api.post("/api/v1/orders", { itemId: "123", qty: 2 });
One-off:
const request = {
method: "post",
url: "/api/v1/orders",
data: { itemId: "123", qty: 2 },
headers: {},
};
signOutgoingRequest(request, sharedSecret);
await api.request(request);
| Argument | Meaning |
|---|---|
request |
Needs method, url, data, headers
|
sharedSecret |
From openssl rand -hex 32 (or secrets store). Empty → request left unsigned |
Headers set on success:
-
x-request-time— same value inside the canonical string -
x-request-signature— hex HMAC of the canonical string
Use whatever header names your backend defines.
Sort the body before hashing
JSON key order is not guaranteed:
{"b":1,"a":2} vs {"a":2,"b":1}
Same meaning, different bytes → different bodyHex → signature mismatch.
Body sort fixes it: both sides run the same key-order pass (sortBodyKeys) before JSON.stringify and sha256Hex. Send that sorted body on the wire so what you transmit matches what you signed.
Challenges I faced
Canonical strings did not match
Even with the correct secret, signatures failed until both sides built the exact same string.
Common causes:
- Field order differs (
timestampbeforepathon one side) - Different newlines / separators
- Method casing (
postvsPOST) - Path shape (
/api/foovs full URL vs trailing slash) - Empty body as
""vs"{}"vs omitted - Body hashed before sort on one side, after sort on the other
Tip: In non-prod, log the full canonical string (or its hash) on client and server and diff them.
Signature still wrong when “logic looks fine”
Usually the message encoding differs, not the crypto:
- Algorithm (
sha256) and digest form (hexvsbase64) - Secret as UTF-8 string vs raw bytes
- Timestamp as string vs number
- Header block casing / trimming
Sequence is the protocol
If the backend expects:
METHOD \n PATH \n TIMESTAMP \n ...
and the client sends:
METHOD \n TIMESTAMP \n PATH \n ...
it will never verify. HMAC will not “figure out” order — you must keep the same sequence as the backend (or document a shared sort both sides apply).
Best practice: mirror the backend
Treat the canonical format as a shared protocol — not a client-only convenience.
| Do | Why |
|---|---|
| Same fields, same order, same separators | HMAC is order-sensitive |
| Sort body keys on both sides | Avoids JSON key-order drift |
| Stabilize only the headers you sign | Keeps signedHeaderBlock identical |
Caution: Do not invent a friendlier order on the client. Align with BE first; any body-sort step must run the same way on both sides.
Checklist
- One written canonicalization spec for mobile, web, and API.
- Bind many request dimensions (method, path, time, secret id, headers, body hash).
- Strong secret via
openssl rand -hex 32; rotate withsecretId. - Server rejects old timestamps (e.g. ±2–5 minutes).
- Hash the body (
bodyHex); do not dump huge payloads raw into HMAC. - Constant-time signature compare on the server.
- Never log secrets; debug canonical strings only in non-prod.
- Fail closed if signing fails when the API requires HMAC.
- Version the scheme if field order ever changes.
- Golden-vector tests: fixed inputs → expected signature on both sides.
Mental model
openssl rand -hex 32 → shared secret
sort body + pick fields → canonical string (fixed order)
HMAC-SHA256 → x-request-signature
server repeats → accept or 401
HMAC itself is simple. The hard part is discipline: identical inputs, identical order, identical encoding. Get the canonical string right on both sides, and request signing becomes boring — in the best way.
If this helped, drop a comment with the gotchas you hit when wiring HMAC on your stack — especially anything platform-specific on iOS/Android.




Top comments (0)