Quick answer
API request signing lets a client prove possession of a shared secret and lets a server detect changes to selected request data. Both sides build the same canonical request, calculate an HMAC, and compare signatures. Add a short timestamp window and an atomically consumed nonce to reject captured-request replay.
Sign the method, normalized path, canonical query, body digest, timestamp, nonce, and key ID context. Define canonicalization as a versioned protocol. Use HmacSHA256 through Java javax.crypto.Mac or createHmac from node:crypto, compare equal-length decoded signatures in constant time, and rotate keys without silently accepting unknown formats.
Request signing provides integrity and client proof; it does not encrypt traffic, authorize an operation, secure a compromised client, or replace TLS.
Threat model
The design addresses an attacker who can alter captured request fields, reuse an old signed request, or guess a signature without the shared key. It also reduces accidental ambiguity between proxies, clients, and servers because the signed representation is defined precisely.
It does not stop:
- theft of the signing key from a client or server;
- a legitimate client signing an unauthorized business action;
- traffic analysis or secret exposure without TLS;
- application bugs after verification;
- replay when nonce storage is unavailable or non-atomic.
Long-lived secrets embedded in public mobile or browser applications are recoverable and should not be treated as confidential. Use server-held credentials, asymmetric proof, or delegated tokens for those clients.
Protocol fields
A practical private HMAC scheme can use:
X-Signature-Version: v1
X-Key-Id: partner-demo
X-Timestamp: 1784995200
X-Nonce: 01K123EXAMPLE8M0P6Y
X-Content-SHA256: BODY_SHA256_HEX
X-Signature: BASE64URL_HMAC
The key ID is an identifier, not a secret. It lets the verifier load the right active or retiring key. The signature version selects one immutable canonicalization rule.
Define the canonical request
For version v1, use this exact newline-delimited input:
HTTP_METHOD
NORMALIZED_PATH
SORTED_QUERY
SHA256_HEX_BODY
TIMESTAMP
NONCE
Example:
POST
/v1/orders
currency=USD&customer=42
7dacf9c63bcfb108c2e298e9a53c0e75681866d5041a73cba714cf250ce6a212
1784995200
01K123EXAMPLE8M0P6Y
Write rules, not intentions:
- Method is uppercase ASCII.
- Path uses the framework’s raw path with a documented percent-encoding normalization; dot segments are rejected.
- Query names and values are decoded once, encoded with the specified UTF-8 percent rules, sorted by encoded name then value, and repeated parameters remain repeated.
- Body digest covers the exact bytes received after transport decoding but before JSON parsing.
- Timestamp is Unix seconds in base 10.
- Nonce is a restricted ASCII identifier with a maximum length.
- Every line uses LF, never platform-specific line endings.
Do not parse JSON and reserialize it for signing unless the protocol defines a complete JSON canonicalization scheme. Whitespace, number formats, and property order can otherwise change.
RFC 9421 defines a general HTTP Message Signatures mechanism with standardized covered components and canonicalization. Prefer a standard when interoperability is required. A private scheme still needs the same precision and a security review.
Verification order
Fail cheaply before expensive or stateful operations:
- Require all headers once; reject duplicates.
- Validate version, key ID syntax, timestamp syntax, nonce syntax, and signature encoding.
- Reject timestamps outside a narrow window such as five minutes.
- Resolve a permitted key for the client and version.
- Read a size-limited raw body and verify its digest.
- Reconstruct the canonical request.
- Calculate and constant-time compare the HMAC.
- Atomically reserve the
(keyId, nonce)until after the timestamp window. - Authenticate the client identity and separately authorize the route.
Do not reserve the nonce before signature verification; unauthenticated traffic could fill the nonce store. Do reserve it before the business mutation begins.
Java implementation
The signer and verifier share the canonicalization function:
record SignedRequest(
String method,
String normalizedPath,
String sortedQuery,
byte[] body,
long timestamp,
String nonce
) {}
static String canonical(SignedRequest request) {
return String.join("\n",
request.method().toUpperCase(Locale.ROOT),
request.normalizedPath(),
request.sortedQuery(),
HexFormat.of().formatHex(sha256(request.body())),
Long.toString(request.timestamp()),
request.nonce()
);
}
static byte[] sha256(byte[] value) {
try {
return MessageDigest.getInstance("SHA-256").digest(value);
} catch (GeneralSecurityException exception) {
throw new IllegalStateException(exception);
}
}
Calculate HMAC through JCA:
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
static byte[] hmacSha256(byte[] secret, String canonical) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
return mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8));
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("HMAC unavailable", exception);
}
}
Verify decoded bytes:
byte[] supplied;
try {
supplied = Base64.getUrlDecoder().decode(signatureHeader);
} catch (IllegalArgumentException exception) {
throw new UnauthorizedException("invalid_signature");
}
byte[] expected = hmacSha256(key.secret(), canonical(request));
if (supplied.length != expected.length ||
!MessageDigest.isEqual(supplied, expected)) {
throw new UnauthorizedException("invalid_signature");
}
if (!nonceRepository.reserve(
key.id(), request.nonce(), Instant.now().plus(Duration.ofMinutes(6)))) {
throw new UnauthorizedException("replayed_request");
}
The repository needs a unique constraint or atomic “set if absent” operation. A read followed by write is race-prone.
Node.js implementation
Use raw body bytes. Express JSON parsing must not discard the bytes needed for verification:
import {
createHash,
createHmac,
timingSafeEqual
} from "node:crypto";
import express from "express";
const app = express();
app.use(express.json({
limit: "1mb",
verify(req, _res, buffer) {
req.rawBody = Buffer.from(buffer);
}
}));
const sha256Hex = (bytes) =>
createHash("sha256").update(bytes).digest("hex");
function canonicalRequest(req, timestamp, nonce) {
return [
req.method.toUpperCase(),
normalizePath(req.originalUrl),
canonicalQuery(req.originalUrl),
sha256Hex(req.rawBody ?? Buffer.alloc(0)),
timestamp,
nonce
].join("\n");
}
Verification:
async function verifySignature(req, res, next) {
try {
const keyId = requireSingleHeader(req, "x-key-id");
const timestamp = requireSingleHeader(req, "x-timestamp");
const nonce = requireSingleHeader(req, "x-nonce");
const suppliedText = requireSingleHeader(req, "x-signature");
const seconds = Number(timestamp);
if (!Number.isSafeInteger(seconds) ||
Math.abs(Date.now() / 1000 - seconds) > 300) {
return res.status(401).json({ error: "invalid_signature" });
}
const key = await keyStore.findActive(keyId);
if (!key) return res.status(401).json({ error: "invalid_signature" });
const expected = createHmac("sha256", key.secret)
.update(canonicalRequest(req, timestamp, nonce), "utf8")
.digest();
const supplied = Buffer.from(suppliedText, "base64url");
if (supplied.length !== expected.length ||
!timingSafeEqual(supplied, expected)) {
return res.status(401).json({ error: "invalid_signature" });
}
if (!await nonceStore.reserve(keyId, nonce, 360)) {
return res.status(401).json({ error: "replayed_request" });
}
req.clientIdentity = key.clientIdentity;
next();
} catch {
return res.status(401).json({ error: "invalid_signature" });
}
}
The surrounding code must also avoid timing leaks where practical; timingSafeEqual only protects the byte comparison.
Key lifecycle
Generate high-entropy keys with a cryptographic random source. Store them in a secret manager, not source control or ordinary configuration files. A database should keep key ID, owning client, algorithm/version, status, creation time, and retirement time; secret material should be protected separately.
Rotation can overlap:
- Issue a new key ID and secret.
- Let the client begin signing with it.
- Accept old and new IDs for a bounded migration window.
- Observe old-key traffic.
- Disable the old key.
- Retain non-secret audit metadata.
Never choose a key based on an unverified client identity. The key ID locates a candidate key; successful HMAC verification establishes possession.
Failure handling
Return 401 for absent, malformed, expired, unknown-key, bad-signature, and replay failures. A stable external error avoids turning the endpoint into a key-enumeration oracle. Internally record a reason category, key ID if syntactically safe, clock delta, route, and correlation ID.
Return 403 only after the signature authenticates a client that lacks permission. Return 400 for independently invalid business input after authentication. Do not include expected signatures, canonical strings containing sensitive data, secrets, or raw Authorization headers in logs.
Operational cost and performance
Hashing and HMAC are linear in signed bytes: O(n) time for a body of n bytes and O(1) additional digest storage when streaming. Nonce defense needs one atomic write per accepted request and temporary storage proportional to request rate times the replay window.
Large bodies should be size-limited and hashed while streaming. Distributed verifiers need a shared, strongly consistent enough nonce reservation mechanism. If availability requirements permit accepting requests without nonce storage, document that replay protection is degraded; the safer default is fail closed.
Clock synchronization is an operational dependency. Monitor skew and use a deliberate window rather than expanding it whenever a host clock drifts.
Tests that matter
- A known test vector produces identical Java and Node.js canonical bytes and HMAC.
- Changed method, path, query value, body byte, timestamp, or nonce fails.
- Reordered query parameters normalize identically under the defined rule.
- Duplicate headers and ambiguous paths are rejected.
- Malformed base64url and wrong-length signatures fail without exceptions escaping.
- Expired and future timestamps fail.
- Two concurrent uses of one nonce yield exactly one success.
- An authenticated but unauthorized key receives 403.
- Old and new keys work during rotation; the retired key fails afterward.
- Empty bodies and repeated query parameters have fixed test vectors.
Keep test vectors in language-neutral fixtures. They expose canonicalization drift earlier than integration testing.
Common mistakes
Signing parsed JSON. Sign exact bytes or adopt a defined canonical JSON standard.
Signing too little. Omitting method, path, query, or body lets an attacker transplant a valid signature.
Timestamp without nonce. A captured request can replay throughout the window.
Non-atomic replay checks. Concurrent requests can both pass.
Plain string comparison. Decode and compare equal-length MAC bytes using timing-resistant primitives.
One permanent key. Design identification, rotation, overlap, and revocation before launch.
Using signatures without TLS. Headers and response data still need confidentiality and server authentication.
Confusing authentication and authorization. A correct signature identifies a key holder; policy decides allowed actions.
Practical checklist
- Prefer RFC 9421 when interoperable HTTP signatures are needed.
- Version a private canonicalization protocol.
- Cover method, normalized target, query, body digest, timestamp, and nonce.
- Reject ambiguous encodings and duplicate security headers.
- Use HMAC-SHA-256 through platform crypto APIs.
- Compare decoded equal-length bytes safely.
- Enforce a narrow clock window.
- Reserve nonces atomically after HMAC verification.
- Store and rotate secrets through a secret manager.
- Separate 401 authentication failure from 403 authorization failure.
- Publish cross-language test vectors.
- Monitor skew, replays, unknown key IDs, and retired-key usage.
Frequently asked questions
Is HMAC request signing encryption?
No. It provides integrity and proof of shared-secret possession. TLS provides transport confidentiality and server authentication.
Why include a body hash?
It binds the signature to the exact payload without inserting arbitrary binary data into the canonical string.
Is a timestamp enough to prevent replay?
No. It only limits replay duration. A nonce or another atomic idempotency record rejects duplicates within that duration.
Should clients sign every header?
Sign fields required by the security meaning. Signing volatile proxy headers creates fragility; omitting relevant content creates substitution risk.
Can browser JavaScript keep an HMAC key?
Not as a durable confidential secret. Users and injected scripts can extract it. Use a backend-held key or a different client-authentication model.
Related reading
- Continue through Backend Security and browse topic clusters.
- Choose credentials with API Authentication Best Practices.
- Protect key material with Secret Management Explained.
- Compare simpler credentials in API Key Design Best Practices.
- Document client authentication with OpenAPI Security Schemes Explained.