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:

HeaderDescriptionExample
X-Hub-TimestampUnix epoch seconds when the request was signed1713264000
X-Hub-NonceRandom string to prevent replay attacksa1b2c3d4e5f6
X-Hub-Signature-256The HMAC signaturesha256=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 (always POST)
  • FULL_URL — the complete request URL as seen by your server
  • SHA256(body) — hex-encoded SHA-256 hash of the raw request body
  • timestamp — value from X-Hub-Timestamp
  • nonce — value from X-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

javascript
// 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 };
}
javascript
// lib/mcp-contract/hmac.js
import { createHash, createHmac, timingSafeEqual } from "crypto";

const TIMESTAMP_TOLERANCE_SEC = 300;

export async function verifyHubSignature(method, url, rawBody, headers) {
  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 };
}
java
// HmacVerificationFilter.java
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;

/**
 * Servlet filter that verifies HMAC-SHA256 signatures on incoming
 * hub-to-tenant requests. Register this filter for /api/mcp/exchange,
 * /api/mcp/validate, /api/mcp/refresh, and /api/mcp/logout.
 */
public class HmacVerificationFilter implements Filter {

    private static final int TIMESTAMP_TOLERANCE_SEC = 300;
    private byte[] hmacKeyBytes;

    @Override
    public void init(FilterConfig config) {
        String hexKey = System.getenv("MCP_CONTRACT_HMAC_KEY");
        if (hexKey == null || hexKey.length() != 64) {
            throw new IllegalStateException(
                "MCP_CONTRACT_HMAC_KEY must be a 64-char hex string");
        }
        hmacKeyBytes = hexToBytes(hexKey);
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res,
                         FilterChain chain) throws IOException, ServletException {
        var request = (HttpServletRequest) req;
        var response = (HttpServletResponse) res;

        // Wrap request so body can be read twice
        var wrappedRequest = new CachedBodyHttpServletRequest(request);
        String rawBody = new String(
            wrappedRequest.getCachedBody(), StandardCharsets.UTF_8);

        String timestamp = request.getHeader("X-Hub-Timestamp");
        String nonce = request.getHeader("X-Hub-Nonce");
        String sigHeader = request.getHeader("X-Hub-Signature-256");

        if (timestamp == null || nonce == null || sigHeader == null) {
            response.setStatus(401);
            response.getWriter().write("{\"error\":\"Missing HMAC headers\"}");
            return;
        }

        // Replay protection: reject if timestamp is too far from now
        long tsNum;
        try {
            tsNum = Long.parseLong(timestamp);
        } catch (NumberFormatException e) {
            response.setStatus(401);
            response.getWriter().write("{\"error\":\"Invalid timestamp\"}");
            return;
        }

        long nowSec = Instant.now().getEpochSecond();
        if (Math.abs(nowSec - tsNum) > TIMESTAMP_TOLERANCE_SEC) {
            response.setStatus(401);
            response.getWriter().write(
                "{\"error\":\"Timestamp out of tolerance\"}");
            return;
        }

        try {
            // SHA-256 hash of the request body
            MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
            String bodyHash = bytesToHex(
                sha256.digest(rawBody.getBytes(StandardCharsets.UTF_8)));

            // Construct the signed message
            String fullUrl = request.getRequestURL().toString();
            if (request.getQueryString() != null) {
                fullUrl += "?" + request.getQueryString();
            }
            String message = String.join("\n",
                request.getMethod(), fullUrl, bodyHash, timestamp, nonce);

            // Compute expected signature
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(hmacKeyBytes, "HmacSHA256"));
            String expectedSig = "sha256=" +
                bytesToHex(mac.doFinal(
                    message.getBytes(StandardCharsets.UTF_8)));

            // Timing-safe comparison
            if (!MessageDigest.isEqual(
                    expectedSig.getBytes(StandardCharsets.UTF_8),
                    sigHeader.getBytes(StandardCharsets.UTF_8))) {
                response.setStatus(401);
                response.getWriter().write(
                    "{\"error\":\"Signature mismatch\"}");
                return;
            }
        } catch (Exception e) {
            response.setStatus(500);
            response.getWriter().write(
                "{\"error\":\"HMAC verification failed\"}");
            return;
        }

        chain.doFilter(wrappedRequest, res);
    }

    private static byte[] hexToBytes(String hex) {
        int len = hex.length();
        byte[] data = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
                + Character.digit(hex.charAt(i + 1), 16));
        }
        return data;
    }

    private static String bytesToHex(byte[] bytes) {
        var sb = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) {
            sb.append(String.format("%02x", b & 0xff));
        }
        return sb.toString();
    }
}
java
// CachedBodyHttpServletRequest.java
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.*;

/**
 * Wraps HttpServletRequest so the body can be read multiple times
 * (once for HMAC verification, once for the controller).
 */
public class CachedBodyHttpServletRequest
        extends HttpServletRequestWrapper {

    private final byte[] cachedBody;

    public CachedBodyHttpServletRequest(HttpServletRequest request)
            throws IOException {
        super(request);
        cachedBody = request.getInputStream().readAllBytes();
    }

    public byte[] getCachedBody() { return cachedBody; }

    @Override
    public ServletInputStream getInputStream() {
        var bais = new ByteArrayInputStream(cachedBody);
        return new ServletInputStream() {
            @Override public int read() { return bais.read(); }
            @Override public boolean isFinished() {
                return bais.available() == 0;
            }
            @Override public boolean isReady() { return true; }
            @Override public void setReadListener(ReadListener l) {}
        };
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(
            new InputStreamReader(getInputStream()));
    }
}
typescript
// lib/mcp-contract/hmac.ts  (Node.js / Express)
import { createHash, createHmac, timingSafeEqual } from "crypto";
import type { Request, Response, NextFunction } from "express";

const TIMESTAMP_TOLERANCE_SEC = 300;

type HmacResult =
  | { ok: true }
  | { ok: false; status: 401; error: string };

export async function verifyHubSignature(
  method: string,
  url: string,
  rawBody: string,
  headers: Headers
): Promise<HmacResult> {
  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 };
}

export async function hmacMiddleware(
  req: Request, res: Response, next: NextFunction
): Promise<void> {
  const rawBody: string = (req as any).rawBody ?? "";
  const fullUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
  const headers = new Headers(req.headers as Record<string, string>);
  const result = await verifyHubSignature(req.method, fullUrl, rawBody, headers);
  if (!result.ok) {
    res.status(result.status).json({ error: result.error });
    return;
  }
  next();
}

Capture the raw body before JSON parsing. Add this once at app startup:

typescript
app.use(express.raw({ type: "*/*" }), (req: any, _res, next) => {
  req.rawBody = req.body ? req.body.toString() : "";
  next();
});
javascript
// lib/mcp-contract/hmac.js
const { createHash, createHmac, timingSafeEqual } = require("crypto");

const TIMESTAMP_TOLERANCE_SEC = 300;

async function verifyHubSignature(method, url, rawBody, headers) {
  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 };
}

// Express middleware — apply to /api/mcp/exchange, /validate, /refresh, /logout
async function hmacMiddleware(req, res, next) {
  const rawBody = req.rawBody ?? "";
  const fullUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
  const headers = new Headers(req.headers);
  const result = await verifyHubSignature(req.method, fullUrl, rawBody, headers);
  if (!result.ok) {
    return res.status(result.status).json({ error: result.error });
  }
  next();
}

module.exports = { verifyHubSignature, hmacMiddleware };

Capture the raw body before JSON parsing. Add this once at app startup:

javascript
app.use(express.raw({ type: "*/*" }), (req, _res, next) => {
  req.rawBody = req.body ? req.body.toString() : "";
  next();
});
python
# mcp_contract/hmac_verify.py
import hashlib
import hmac as _hmac
import os
import secrets
import time

from fastapi import HTTPException, Request

TIMESTAMP_TOLERANCE_SEC = 300


async def verify_hub_signature(request: Request) -> None:
    key_hex = os.environ.get("MCP_CONTRACT_HMAC_KEY", "")
    if not key_hex:
        raise HTTPException(401, detail="HMAC key not configured")

    timestamp = request.headers.get("x-hub-timestamp")
    nonce = request.headers.get("x-hub-nonce")
    sig_header = request.headers.get("x-hub-signature-256")

    if not (timestamp and nonce and sig_header):
        raise HTTPException(401, detail="Missing HMAC headers")

    try:
        ts_num = int(timestamp)
    except ValueError:
        raise HTTPException(401, detail="Invalid timestamp")

    if abs(time.time() - ts_num) > TIMESTAMP_TOLERANCE_SEC:
        raise HTTPException(401, detail="Timestamp out of tolerance")

    raw_body = await request.body()
    body_hash = hashlib.sha256(raw_body).hexdigest()
    message = f"POST\n{request.url}\n{body_hash}\n{timestamp}\n{nonce}"

    # Decode hex key to raw bytes
    key = bytes.fromhex(key_hex)
    expected_sig = "sha256=" + _hmac.new(
        key, message.encode(), hashlib.sha256
    ).hexdigest()

    # Timing-safe comparison
    if not secrets.compare_digest(expected_sig.encode(), sig_header.encode()):
        raise HTTPException(401, detail="Signature mismatch")