Guide · MCP Security

MCP server request signing

Request signing uses a shared secret to attach a cryptographic signature to outbound HTTP requests. The receiver verifies the signature before processing the payload, confirming that the request came from the expected sender and has not been tampered with in transit. For MCP servers that send or receive webhook callbacks — from external orchestrators, CI/CD systems, payment providers, or monitoring services — request signing is the standard mechanism to authenticate those callbacks without exposing credentials in HTTP headers.

TL;DR

HMAC-SHA256 is the standard algorithm: compute HMAC-SHA256(secret, timestamp + '.' + body), attach as X-Signature: sha256=<hex>. On the receiving end: recompute the same HMAC over the raw body (before JSON parsing), compare using constant-time comparison (never ===), and reject requests whose timestamp is more than 5 minutes old to prevent replay attacks. Buffer the raw request body before parsing — most frameworks overwrite the raw bytes once JSON-parsed.

When MCP servers need request signing

Two distinct scenarios arise in MCP server deployments:

The same HMAC-SHA256 algorithm applies in both directions. This guide covers both.

Generating a signature (sender side)

import { createHmac } from 'crypto';

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!; // 32+ random bytes, hex-encoded

function signPayload(secret: string, body: string): string {
  // Include timestamp to prevent replay — receiver will validate the window
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signed = `${timestamp}.${body}`;
  const sig = createHmac('sha256', secret).update(signed).digest('hex');
  return `sha256=${sig}`;
}

// When sending a webhook from your MCP server:
async function sendWebhook(url: string, payload: object) {
  const body = JSON.stringify(payload);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signature = signPayload(WEBHOOK_SECRET, body);

  await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Signature': signature,
      'X-Timestamp': timestamp,
    },
    body,
  });
}

The signature covers both the timestamp and the body so that tampering with either one invalidates the signature. Sending the timestamp as a separate header (X-Timestamp) lets the receiver validate the timing window before doing the cryptographic check.

Verifying a signature (receiver side)

import { createHmac, timingSafeEqual } from 'crypto';
import express from 'express';

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;
const MAX_AGE_SECONDS = 300; // Reject requests older than 5 minutes

function verifySignature(
  secret: string,
  rawBody: Buffer,
  receivedSig: string,
  timestamp: string,
): boolean {
  // 1. Validate timestamp window (replay attack prevention)
  const now = Math.floor(Date.now() / 1000);
  const ts = parseInt(timestamp, 10);
  if (isNaN(ts) || Math.abs(now - ts) > MAX_AGE_SECONDS) {
    return false; // Too old or too far in the future (clock skew attack)
  }

  // 2. Recompute HMAC over timestamp + raw body
  const signed = `${timestamp}.${rawBody.toString('utf8')}`;
  const expected = 'sha256=' + createHmac('sha256', secret).update(signed).digest('hex');

  // 3. Constant-time comparison — prevents timing oracle attacks
  if (expected.length !== receivedSig.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(receivedSig));
}

// Express middleware: capture raw body before JSON parse
function rawBodyMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) {
  const chunks: Buffer[] = [];
  req.on('data', chunk => chunks.push(chunk));
  req.on('end', () => {
    (req as any).rawBody = Buffer.concat(chunks);
    // Also parse as JSON for downstream handlers
    try {
      req.body = JSON.parse((req as any).rawBody.toString('utf8'));
    } catch {
      req.body = {};
    }
    next();
  });
}

function requireSignature(req: express.Request, res: express.Response, next: express.NextFunction) {
  const sig = req.headers['x-signature'] as string | undefined;
  const ts  = req.headers['x-timestamp']  as string | undefined;
  const raw = (req as any).rawBody as Buffer | undefined;

  if (!sig || !ts || !raw) {
    return res.status(401).json({ error: 'Missing signature headers' });
  }

  if (!verifySignature(WEBHOOK_SECRET, raw, sig, ts)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  next();
}

// Apply to webhook routes
const app = express();
app.post('/webhook/agent-event', rawBodyMiddleware, requireSignature, (req, res) => {
  const event = req.body;
  // Process the verified event
  res.json({ received: true });
});

The rawBodyMiddleware is critical: Express's built-in express.json() consumes and parses the request body, destroying the raw bytes. You must capture the raw buffer before parsing because the HMAC is computed over the original bytes — not a re-serialized JSON string.

Why constant-time comparison matters

A naive string comparison (expected === received) leaks timing information. The comparison returns false faster when the first differing character is early — an attacker making thousands of requests with slightly varied signatures can measure the response time and infer the correct bytes one at a time. This is a timing oracle attack.

timingSafeEqual from Node's crypto module compares two buffers in constant time regardless of where they differ. It requires buffers of equal length — hence the length check before calling it. A length mismatch is returned immediately (leaking the length, but not the content — that's acceptable because valid signatures always have the same length).

// Never do this
if (expected === received) { /* vulnerable to timing oracle */ }

// Always do this
import { timingSafeEqual } from 'crypto';
if (expected.length !== received.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(received));

Replay attack prevention

A valid signature on an old request can be replayed indefinitely if you don't validate the timestamp. An attacker who intercepts a signed webhook can re-send it hours later. The timestamp window prevents this:

A 5-minute window (300 seconds) is the industry convention (Stripe, GitHub, Slack all use it). Set it shorter if your clock synchronization is reliable; longer if you have sender/receiver clock skew problems. Do not set it to zero — even small NTP drift can cause false rejections.

For even stronger replay protection, track a nonce (a per-request UUID) in a short-lived store (Redis, in-memory) and reject duplicate nonces within the window. This catches replays of very recent requests before the window expires.

GitHub-style signature format

If you are implementing a webhook receiver that must be compatible with GitHub's webhook format, the header format differs slightly — GitHub sends X-Hub-Signature-256 and does not include a timestamp (replay protection is not part of their spec):

function verifyGithubSignature(secret: string, rawBody: Buffer, sigHeader: string): boolean {
  const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
  if (expected.length !== sigHeader.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(sigHeader));
}

app.post('/webhook/github', rawBodyMiddleware, (req, res) => {
  const sig = req.headers['x-hub-signature-256'] as string;
  if (!sig || !verifyGithubSignature(WEBHOOK_SECRET, (req as any).rawBody, sig)) {
    return res.status(401).json({ error: 'Invalid GitHub signature' });
  }
  // Process push/PR/issue event
  res.status(200).send('ok');
});

Testing signature verification

import { createHmac } from 'crypto';
import request from 'supertest';
import app from './app';

const SECRET = 'test-secret-32-bytes-xxxxxxxxxx';
process.env.WEBHOOK_SECRET = SECRET;

function signBody(body: string, ts?: number): { sig: string; timestamp: string } {
  const timestamp = (ts ?? Math.floor(Date.now() / 1000)).toString();
  const sig = 'sha256=' + createHmac('sha256', SECRET).update(`${timestamp}.${body}`).digest('hex');
  return { sig, timestamp };
}

test('accepts valid signature', async () => {
  const body = JSON.stringify({ event: 'tool.called', tool: 'search_files' });
  const { sig, timestamp } = signBody(body);
  const res = await request(app)
    .post('/webhook/agent-event')
    .set('Content-Type', 'application/json')
    .set('X-Signature', sig)
    .set('X-Timestamp', timestamp)
    .send(body);
  expect(res.status).toBe(200);
});

test('rejects tampered body', async () => {
  const body = JSON.stringify({ event: 'tool.called', tool: 'search_files' });
  const { sig, timestamp } = signBody(body);
  const res = await request(app)
    .post('/webhook/agent-event')
    .set('Content-Type', 'application/json')
    .set('X-Signature', sig)
    .set('X-Timestamp', timestamp)
    .send(JSON.stringify({ event: 'tool.called', tool: 'delete_everything' })); // tampered
  expect(res.status).toBe(401);
});

test('rejects stale timestamp', async () => {
  const body = JSON.stringify({ event: 'tool.called' });
  const staleTs = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago
  const { sig, timestamp } = signBody(body, staleTs);
  const res = await request(app)
    .post('/webhook/agent-event')
    .set('Content-Type', 'application/json')
    .set('X-Signature', sig)
    .set('X-Timestamp', timestamp)
    .send(body);
  expect(res.status).toBe(401);
});

Further reading