@amrshbib/react-native-hmac
v0.0.1
Published
Native HMAC-SHA256 signer for React Native. The signing secret stays in native code; the JS layer never holds it. Plug-and-play helpers for socket.io, fetch, and axios.
Maintainers
Readme
react-native-hmac
Native HMAC-SHA256 signer for React Native. Returns a fresh, replay-resistant signature you can attach to any transport — Socket.IO, fetch / axios, SignalR, raw WebSocket, gRPC-Web, MQTT — anywhere you need to prove that a request came from your app.
The signing secret lives only in compiled native resources (Android string
resource / iOS Info.plist). The JS layer never holds it, so it cannot be
extracted from the Hermes/JSC bundle.
- One synchronous function.
getSignature()returns{ signature, timestamp, version }— no Promise, noawait. HMAC over the small canonical payload completes in under a millisecond, so the cost of blocking the JS thread is negligible. - Transport-agnostic. Zero coupling to any specific networking library.
- Native signing.
javax.crypto.Macon Android,CryptoKiton iOS. - Zero JS crypto dependencies. No
crypto-js, noreact-native-quick-crypto. - Zero secret exposure to JS. No
react-native-config, no.envbundling. - Replay-resistant. Every signature includes a fresh timestamp.
Requires Hermes or on-device JSC. The native method is declared as a blocking synchronous bridge call. It does not work in the Chrome remote JS debugger. It works fine in Hermes (default in modern RN), in the Hermes/JSC debugger, and on real devices.
Quick start
yarn add react-native-hmac
cd ios && pod installimport { getSignature } from "react-native-hmac";
const { signature, timestamp, version } = getSignature();
// → { signature: "ab12…", timestamp: 1716220000000, version: "v1" }That's the whole client-side API. No await, no promise, no setup beyond configuring the native secret once (below).
Configure the native secret (one-time per app)
Generate the secret:
openssl rand -hex 32Android — one line
Add to android/local.properties (already in .gitignore):
HMAC_SECRET=...your-generated-secret...That's it. The library's own build.gradle reads this value at build time and
injects it as the Android string resource react_native_hmac_secret, which is
merged into your APK's resources.arsc. Nothing to add to your app's
build.gradle.
Alternative sources (checked in order):
-PHMAC_SECRET=...Gradle property — handy for CI.HMAC_SECRETenvironment variable.HMAC_SECRETkey inandroid/local.properties.
To override per-flavor or per-build-type, declare your own resValue with the
same name in android/app/build.gradle — app-level resValues win over
library-level ones during Android's resource merge.
iOS
Create
ios/Secrets.xcconfig(gitignored):REACT_NATIVE_HMAC_SECRET = ...your-generated-secret...Reference it from your target's xcconfig.
Add to
ios/<YourApp>/Info.plist:<key>ReactNativeHmacSecret</key> <string>$(REACT_NATIVE_HMAC_SECRET)</string>
Recipes — same getSignature(), any transport
Socket.IO
import io from "socket.io-client";
import { getSignature } from "react-native-hmac";
const socket = io(url, {
auth: (cb) => cb(getSignature()),
});The function form of auth is called by socket.io-client on every (re)connect,
so the timestamp is always fresh.
Fetch
import { getSignature } from "react-native-hmac";
const { signature, timestamp, version } = getSignature();
const res = await fetch(url, {
headers: {
"x-hmac-signature": signature,
"x-hmac-timestamp": String(timestamp),
"x-hmac-version": version,
},
});Axios (request interceptor)
import axios from "axios";
import { getSignature } from "react-native-hmac";
const api = axios.create({ baseURL: "https://api.example.com" });
api.interceptors.request.use((config) => {
const { signature, timestamp, version } = getSignature();
config.headers["x-hmac-signature"] = signature;
config.headers["x-hmac-timestamp"] = String(timestamp);
config.headers["x-hmac-version"] = version;
return config;
});SignalR
import { HubConnectionBuilder } from "@microsoft/signalr";
import { getSignature } from "react-native-hmac";
const connection = new HubConnectionBuilder()
.withUrl(url, {
accessTokenFactory: () => {
const { signature, timestamp } = getSignature();
return `${timestamp}.${signature}`;
},
})
.build();Raw WebSocket
import { getSignature } from "react-native-hmac";
const { signature, timestamp } = getSignature();
const ws = new WebSocket(`${url}?ts=${timestamp}&sig=${signature}`);Reusable signer with bound claims
import { createSigner } from "react-native-hmac";
const signWithTenant = createSigner({ claims: { tenantId: "t_42" } });
const a = signWithTenant(); // fresh timestamp, same claim bound
const b = signWithTenant(); // fresh timestamp, same claim boundStartup health-check
import { isConfigured } from "react-native-hmac";
if (!isConfigured()) {
// The native secret wasn't wired up in this build.
}API
getSignature(options?: SignOptions): Signature
createSigner(options?: SignOptions): () => Signature
isConfigured(): boolean
buildPayloadString(timestamp: number, claims?): string
class HmacNotLinkedError // app not rebuilt
class HmacSecretMissingError // native resource not populated
class HmacSignError // anything elseEverything is synchronous. Errors are thrown synchronously — wrap in try/catch if you want graceful degradation; otherwise let them propagate so missing configuration fails loud and early.
Options
type SignOptions = {
/** Bound INTO the HMAC. The verifier must receive the same keys/values
* and recompute the canonical payload to validate. */
claims?: Record<string, string | number | boolean>;
};
type Signature = {
signature: string; // lowercase hex
timestamp: number; // epoch ms
version: "v1";
};Canonical payload
The native side hashes one of:
v1:<timestamp>
v1:<timestamp>:<sortedJsonClaims>Claims are JSON-stringified with keys sorted alphabetically and no whitespace — any conformant JSON encoder reproduces the exact bytes, so any verifier on the other end can recompute the same string.
Verifying signatures (any backend / any language)
The canonical payload format is trivial to reproduce. Node.js example:
const { createHmac, timingSafeEqual } = require("node:crypto");
function canonicalPayload(timestamp, claims) {
if (!claims || Object.keys(claims).length === 0) return `v1:${timestamp}`;
const sorted = {};
for (const k of Object.keys(claims).sort()) sorted[k] = claims[k];
return `v1:${timestamp}:${JSON.stringify(sorted)}`;
}
function verify({ secret, signature, timestamp, claims, windowMs = 60_000 }) {
if (Math.abs(Date.now() - timestamp) > windowMs) return false;
const expected = createHmac("sha256", secret)
.update(canonicalPayload(timestamp, claims))
.digest("hex");
if (expected.length !== signature.length) return false;
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
}Use the same secret on both sides — the value in android/local.properties
on the device build must equal process.env.HMAC_SECRET (or wherever) on your
backend.
Always check the timestamp is within a small window of now (60 s is a
sensible default) to prevent replay.
Security FAQ
Q. Why not just use crypto-js and react-native-config?
Because both put the secret in your JS bundle. react-native-config injects
.env values at build time as JS constants; crypto-js then needs the
secret in memory. An attacker with the APK can run
strings index.android.bundle | grep. This library keeps the secret in
native resources and computes HMAC inside Kotlin/Swift — JS never sees the
key, even at runtime.
Q. Can the secret still be extracted?
On a rooted device with Frida, an attacker can hook Mac.doFinal() and observe
the key in process memory. This is unsolvable for any pure client-side scheme.
Mitigations: root/jailbreak detection, certificate pinning, server-side
anomaly detection, periodic server-driven secret rotation.
Q. Why HMAC and not JWT or asymmetric signatures? HMAC is symmetric — one shared secret, simpler ops, ~10× faster signing than ECDSA. If you also need per-user identity, layer a JWT on top of an HMAC-signed request.
Q. Why is the API synchronous? Doesn't that block the JS thread?
The native bridge call is declared as a blocking synchronous method. HMAC
over a tiny payload completes in well under a millisecond, so the thread is
unblocked before a single frame is dropped. The benefit is a dramatically
cleaner API — no async contagion through your code just because you wanted
to sign a request.
Q. Does this work in Expo Go?
No — Expo Go does not include arbitrary native modules. Use a development
build (eas build --profile development) or the bare workflow.
Q. Does this work in the Chrome remote JS debugger? No — synchronous native calls aren't supported in the Chrome debugger. Use Hermes inspector or on-device debugging. (Most modern RN setups use Hermes by default.)
License
MIT
