Signing API Requests with HMAC in React Native

7 min read
Share:

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:

  1. A message string
  2. 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

  1. One written canonicalization spec for mobile, web, and API.
  2. Bind many request dimensions (method, path, time, secret id, headers, body hash).
  3. Strong secret via openssl rand -hex 32; rotate with secretId.
  4. Server rejects old timestamps (e.g. ±2–5 minutes).
  5. Hash the body (bodyHex); do not dump huge payloads raw into HMAC.
  6. Constant-time signature compare on the server.
  7. Never log secrets; debug canonical strings only in non-prod.
  8. Fail closed if signing fails when the API requires HMAC.
  9. Version the scheme if field order ever changes.
  10. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *