HMAC Signing
The hub signs all server-to-server requests (exchange, validate, refresh, logout) with HMAC-SHA256. Your app must verify the signature on every request to these endpoints.
Applies to Tenant Delegated connections only. Identity Brokering and Token Exchange connections don't use HMAC signing.
This is a how-to guide. The Contract Specification is the normative reference for the HMAC formula, headers, and rules.
Wire format
The hub sends three headers with each signed request:
| Header | Description | Example |
|---|---|---|
X-Hub-Timestamp | Unix epoch seconds when the request was signed | 1713264000 |
X-Hub-Nonce | Random string to prevent replay attacks | a1b2c3d4e5f6 |
X-Hub-Signature-256 | The HMAC signature | sha256=abcdef1234... |
Message construction
The signed message is constructed as:
{METHOD}\n{FULL_URL}\n{SHA256(body)}\n{timestamp}\n{nonce}Where:
METHOD— HTTP method in uppercase (alwaysPOST)FULL_URL— the complete request URL as seen by your serverSHA256(body)— hex-encoded SHA-256 hash of the raw request bodytimestamp— value fromX-Hub-Timestampnonce— value fromX-Hub-Nonce
Key encoding
The HMAC key in your env var is a 64-character hex string. You must decode it to 32 raw bytes before using it as the HMAC key. Do NOT use the hex string directly.
Replay protection
Reject requests where the timestamp differs from the current time by more than 300 seconds (5 minutes). This prevents replay attacks using captured signatures.
Timing-safe comparison
Always use a constant-time comparison function when comparing signatures. Using === or .equals() leaks timing information that can be used to forge signatures.
Complete verification implementation
// lib/mcp-contract/hmac.ts
import { createHash, createHmac, timingSafeEqual } from "crypto";
const TIMESTAMP_TOLERANCE_SEC = 300;
export async function verifyHubSignature(
method: string,
url: string,
rawBody: string,
headers: Headers
): Promise<{ ok: true } | { ok: false; status: 401; error: string }> {
const hmacKeyHex = process.env.MCP_CONTRACT_HMAC_KEY ?? "";
if (!hmacKeyHex) {
return { ok: false, status: 401, error: "HMAC key not configured" };
}
const timestamp = headers.get("x-hub-timestamp");
const nonce = headers.get("x-hub-nonce");
const sigHeader = headers.get("x-hub-signature-256");
if (!timestamp || !nonce || !sigHeader) {
return { ok: false, status: 401, error: "Missing HMAC headers" };
}
const tsNum = parseInt(timestamp, 10);
if (isNaN(tsNum)) {
return { ok: false, status: 401, error: "Invalid timestamp" };
}
const nowSec = Math.floor(Date.now() / 1000);
if (Math.abs(nowSec - tsNum) > TIMESTAMP_TOLERANCE_SEC) {
return { ok: false, status: 401, error: "Timestamp out of tolerance" };
}
// Hash the raw body
const bodyHash = createHash("sha256").update(rawBody, "utf8").digest("hex");
// Construct the message
const message = `${method}\n${url}\n${bodyHash}\n${timestamp}\n${nonce}`;
// Decode hex key to raw bytes
const key = Buffer.from(hmacKeyHex, "hex");
const expectedSig = createHmac("sha256", key)
.update(message, "utf8")
.digest("hex");
const expectedHeader = `sha256=${expectedSig}`;
// Timing-safe comparison
const expected = Buffer.from(expectedHeader, "utf8");
const actual = Buffer.from(sigHeader, "utf8");
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
return { ok: false, status: 401, error: "Signature mismatch" };
}
return { ok: true };
}