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:
- MCP server receives webhooks — an external service (GitHub, Stripe, a CI system, or an agent orchestrator) calls a route on your MCP server when an event occurs. You need to verify these are legitimate and not spoofed or replayed by an attacker.
- MCP server sends signed callbacks — your server calls out to external services and signs those requests so the receiver can verify they came from you. This is common when your MCP server is acting as a producer in an event pipeline.
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:
- The sender includes the current Unix timestamp (seconds) in the signature and as a header
- The receiver computes
|now - timestamp| <= MAX_AGE_SECONDSbefore checking the HMAC - Replays older than the window are rejected even if the signature is cryptographically valid
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
- MCP server authentication — JWT, API keys, and session verification
- MCP server webhooks — triggering external events from tool calls
- MCP server webhook alerts — Slack and PagerDuty integration
- MCP server audit logging — record every tool call with actor and args
- MCP server secrets management — storing and rotating signing keys
- MCP server input validation — Zod schemas and boundary checks
- MCP server middleware — Express middleware patterns
- AliveMCP — uptime monitoring for HTTP-deployed MCP servers