Introduction
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.
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 |
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 |
Signing only method + path is 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](https://github.com/margelo/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 (timestamp before path on one side)
- Different newlines / separators
- Method casing (post vs POST)
- Path shape (/api/foo vs full URL vs trailing slash)
- Empty body as “” vs “{}” vs omitted
- Body hashed before sort on one side, after sort on the other
Fix: 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 (hex vs base64)
- 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 |
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 with secretId.
- 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.