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 Side

Ironclad 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 Key

Fetch 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"
}
FieldTypeDescription
encodingstringBinary-to-text encoding of the signature. Today always base64.
noncestringA random value, 16 hex characters.
signAlgorithmstringHashing algorithm used for signing. Today always sha256, meaning RSA-SHA256.
signaturestringThe signature over the reconstructed string, in the stated encoding.
timestampstring (ISO-8601)When Ironclad signed the request.

Verification Steps

  1. Parse the header JSON.

  2. 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 nonce
    ComponentValue
    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 the GET status and document calls.
    timestampThe timestamp value from the header.
    nonceThe nonce value from the header.
  3. Verify signature against that string using RSA-SHA256 and the Ironclad webhook verification public key.

📌

Verify Against the Raw Bytes

Ironclad 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 body form part only. The document_N parts 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 Path

Ironclad 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 endpointUrl path 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:

  1. Verify the signature over the body form-part string, as described above. This authenticates the JSON body, including documentHashes.
  2. For each uploaded document_N part, compute SHA-256 over the raw file bytes and hex-encode it.
  3. Compare each computed digest to documentHashes[N], matched by index (document_0 to documentHashes[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 Today

Ironclad 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

timestamp and nonce are 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 timestamp falls outside an acceptable skew window, for example five minutes either side, and
  • track recently seen nonce values and reject reuse within that window.

If you implement this, record a nonce only 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.


Did this page help you?