DEV Community

Cover image for Signing API Requests with HMAC (React Native)
Amit Kumar
Amit Kumar

Posted on

Signing API Requests with HMAC (React Native)

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

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:

  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)
Enter fullscreen mode Exit fullscreen mode

Same canonical string + same secret = match. Anything else fails.


Generate a strong secret

openssl rand -hex 32
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Example:

POST
/api/v1/orders
1710000000000
ios
mobile-app-v1
accept:application/json;x-api-version:1
a3f2c9...   ← sha256 hex of the body
Enter fullscreen mode Exit fullscreen mode

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 + 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. 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;
};
Enter fullscreen mode Exit fullscreen mode
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 });
Enter fullscreen mode Exit fullscreen mode

One-off:

const request = {
  method: "post",
  url: "/api/v1/orders",
  data: { itemId: "123", qty: 2 },
  headers: {},
};

signOutgoingRequest(request, sharedSecret);
await api.request(request);
Enter fullscreen mode Exit fullscreen mode
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}
Enter fullscreen mode Exit fullscreen mode

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

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 (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 ...
Enter fullscreen mode Exit fullscreen mode

and the client sends:

METHOD \n TIMESTAMP \n PATH \n ...
Enter fullscreen mode Exit fullscreen mode

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

  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
Enter fullscreen mode Exit fullscreen mode

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)