Request Signing
Verify the Ironclad request signature, check document integrity, and protect against replay on every outbound call.
Overview
Ironclad signs every request it sends to your middleware (i.e. initiate, status, cancel, remind, and document) by attaching an X-Ironclad-Request-Signature header. Signing is unconditional: it happens on every call, and it is independent of whether mTLS is configured.
Verification Happens on Your SideIronclad signs the request, but it is your responsibility to verify it. Ironclad cannot check the signature on your behalf, and has no visibility into whether you do, so we strongly recommend verifying on every call, before acting on the request, and rejecting anything that fails. An endpoint that skips the check will accept forged requests from anyone who can reach its URL.
The Verification Key
Ironclad signs outbound requests with the same keypair used for Ironclad platform webhooks, so you verify them with the Ironclad webhook verification public key. No separate key is issued for BYOSP.
Retrieve it from GET /public/api/v1/webhooks/verification-key, which returns the key in PEM format.
Cache the KeyFetch the key once and cache it rather than retrieving it on every request. On a verification failure, re-fetch once and re-verify before rejecting, so that a key rotation does not take your middleware down.
The Signature Header
The X-Ironclad-Request-Signature header value is a JSON object. Ironclad serializes the keys in sorted order, but parse it as ordinary JSON rather than relying on that ordering:
{
"encoding": "base64",
"nonce": "d1f9c0e2ab34...",
"signAlgorithm": "sha256",
"signature": "<base64 signature>",
"timestamp": "2026-01-15T09:30:00.000Z"
}| Field | Type | Description |
|---|---|---|
encoding | string | Binary-to-text encoding of the signature. Today always base64. |
nonce | string | A random value, 16 hex characters. |
signAlgorithm | string | Hashing algorithm used for signing. Today always sha256, meaning RSA-SHA256. |
signature | string | The signature over the reconstructed string, in the stated encoding. |
timestamp | string (ISO-8601) | When Ironclad signed the request. |
Verification Steps
-
Parse the header JSON.
-
Reconstruct the signed string by joining these five fields with a single newline (
\n), in exactly this order:METHOD \n path \n body \n timestamp \n nonceComponent Value METHODThe HTTP method, uppercase ( POST,GET, and so on).pathThe URL path only, with no scheme, host, port, or query string. bodyThe request body string exactly as sent on the wire. Use an empty string ( "") for requests with no body, such as theGETstatus and document calls.timestampThe timestampvalue from the header.nonceThe noncevalue from the header. -
Verify
signatureagainst that string using RSA-SHA256 and the Ironclad webhook verification public key.
Verify Against the Raw BytesIronclad signs the request body as-is, so verify against the raw bytes you received rather than re-serializing the parsed body, since key ordering and whitespace will differ if you do.
- JSON calls (cancel, remind): the JSON body string.
- Multipart initiate: the JSON string of the
bodyform part only. Thedocument_Nparts are not in the signed string. See Document Integrity below.
Example Implementation
The following is a Node.js reference implementation. The algorithm above is what matters; implement it in whichever language your middleware is written in.
import { type BinaryToTextEncoding, createVerify } from 'node:crypto';
const SIGNATURE_HEADER = 'x-ironclad-request-signature';
interface ParsedSignature {
signature: string;
signAlgorithm: string; // today always 'sha256' (-> RSA-SHA256)
nonce: string;
encoding: BinaryToTextEncoding; // today always 'base64'
timestamp: string; // ISO-8601
}
/** Parse and shape-check the header */
function parseSignatureHeader(
headerValue: string | undefined,
): { ok: true; sig: ParsedSignature } | { ok: false; reason: string } {
if (headerValue == null) return { ok: false, reason: `missing ${SIGNATURE_HEADER} header` };
let parsed: Partial<ParsedSignature>;
try {
parsed = JSON.parse(headerValue) as Partial<ParsedSignature>;
} catch {
return { ok: false, reason: 'signature header is not valid JSON' };
}
const { signature, signAlgorithm, nonce, encoding, timestamp } = parsed;
if (
typeof signature !== 'string' ||
typeof signAlgorithm !== 'string' ||
typeof nonce !== 'string' ||
typeof encoding !== 'string' ||
typeof timestamp !== 'string'
) {
return { ok: false, reason: 'signature header missing required field(s)' };
}
return {
ok: true,
sig: { signature, signAlgorithm, nonce, encoding: encoding as BinaryToTextEncoding, timestamp },
};
}
/** Reconstruct the signed string: METHOD \n path \n body \n timestamp \n nonce. */
function buildSignedString(
method: string,
urlPath: string,
body: string | undefined,
sig: ParsedSignature,
): string {
return [method.toUpperCase(), urlPath, body ?? '', sig.timestamp, sig.nonce].join('\n');
}
/** Verify the signature against the request. */
function verifyRequest(
publicKeyPem: string,
sig: ParsedSignature,
method: string,
urlPath: string, // the path Ironclad called, NOT the post-proxy-rewrite path
body: string | undefined,
): { ok: boolean; reason?: string } {
const dataToSign = buildSignedString(method, urlPath, body, sig);
try {
const verifier = createVerify(sig.signAlgorithm); // 'sha256' -> RSA-SHA256
verifier.update(dataToSign, 'utf8');
verifier.end();
if (verifier.verify(publicKeyPem, sig.signature, sig.encoding)) return { ok: true };
return { ok: false, reason: 'signature did not verify' };
} catch (err) {
return { ok: false, reason: `verify threw: ${err instanceof Error ? err.message : String(err)}` };
}
}Capturing the exact signed body with Express:
import express, { type Request } from 'express';
interface RawBodyRequest extends Request { rawBody?: string; }
// express.json that also stashes the exact raw body string, which is what Ironclad signed.
const jsonWithRaw = express.json({
verify: (req, _res, buf) => {
(req as RawBodyRequest).rawBody = buf.length > 0 ? buf.toString('utf8') : '';
},
});
// Then, per route:
// const sig = parseSignatureHeader(req.header('x-ironclad-request-signature'));
// const urlPath = (req.originalUrl || req.url).split('?')[0];
// verifyRequest(pem, sig, req.method, urlPath, rawBody); // rawBody = '' for bodyless GETs
//
// The multipart initiate route does NOT use rawBody. Parse the form first (for example with
// multer's upload.any()), then verify against the `body` form field exactly as received:
// verifyRequest(pem, sig, req.method, urlPath, req.body.body);Path Reconstruction Behind a Proxy
Preserve the Original PathIronclad signs the path as Ironclad sees it, with no host and no query string. If a reverse proxy or API gateway in front of your middleware rewrites the path (i.e. stripping or adding a prefix) before your verifier sees the request, you will reconstruct a different path and verification will fail.
Verify against the original path Ironclad called (the configured
endpointUrlpath plus the documented suffix), not the post-rewrite path your application framework reports. Capture the original path before any proxy rewrite, or configure the proxy to preserve it.
Document Integrity
For the multipart initiate call, the signature covers only the JSON body form part. The document_N byte parts are not in the signed string. Document integrity is bound to the signature indirectly, through the documentHashes array inside that JSON body.
To confirm the documents you received are the ones Ironclad sent, we recommend:
- Verify the signature over the
bodyform-part string, as described above. This authenticates the JSON body, includingdocumentHashes. - For each uploaded
document_Npart, compute SHA-256 over the raw file bytes and hex-encode it. - Compare each computed digest to
documentHashes[N], matched by index (document_0todocumentHashes[0], and so on), and reject the request on any mismatch or count mismatch.
This chain (signature to body to documentHashes to file bytes) is what lets your middleware trust that the received PDFs are exactly what Ironclad sent. Hashing the received files without comparing them to the signed documentHashes proves nothing.
Single Document TodayIronclad currently sends one document per signature request. Multipart handling for multiple documents is for future compatibility.
Example Implementation
import { createHash } from 'node:crypto';
/** SHA-256 hex digest, used to check multipart file integrity against documentHashes. */
function sha256Hex(buf: Buffer): string {
return createHash('sha256').update(buf).digest('hex');
}
/**
* File integrity: each uploaded file's SHA-256 must match documentHashes in upload order.
* `files` must be sorted by fieldname numerically first (document_0, document_1, …) so that
* files[N] lines up with documentHashes[N]. Reject on any mismatch or count mismatch.
*/
function checkDocumentHashes(
files: { fieldname: string; buffer: Buffer }[],
documentHashes: string[] | undefined,
): { ok: boolean; reason?: string } {
if (files.length === 0) return { ok: true };
if (documentHashes == null || documentHashes.length !== files.length) {
return {
ok: false,
reason: `documentHashes count (${documentHashes?.length ?? 0}) does not match uploaded files (${files.length})`,
};
}
for (let i = 0; i < files.length; i += 1) {
const actual = sha256Hex(files[i].buffer);
if (documentHashes[i] !== actual) {
return {
ok: false,
reason: `${files[i].fieldname} SHA-256 mismatch (expected ${documentHashes[i]}, got ${actual})`,
};
}
}
return { ok: true };
}Sorting the multipart parts into upload order before the comparison, using multer's upload.any():
const files = Array.isArray(req.files) ? req.files : [];
const documentFiles = files
.filter((f) => f.fieldname.startsWith('document_'))
.sort((a, b) => a.fieldname.localeCompare(b.fieldname, undefined, { numeric: true }));
const hashCheck = checkDocumentHashes(documentFiles, body.documentHashes);
if (!hashCheck.ok) {
return res.status(401).json({ error: `document integrity check failed: ${hashCheck.reason}` });
}Replay Protection
Replay Protection Happens on Your Side
timestampandnonceare inside the signed payload, so they cannot be tampered with, but Ironclad only supplies them and does not enforce freshness or uniqueness on your behalf. If you want protection against a captured request being replayed, we recommend that your middleware:
- reject requests whose
timestampfalls outside an acceptable skew window, for example five minutes either side, and- track recently seen
noncevalues and reject reuse within that window.If you implement this, record a
nonceonly after its signature has verified. Recording on receipt lets unverified traffic fill your cache and, worse, lets an attacker burn a nonce that a legitimate request is about to use.
Updated about 7 hours ago

