Topic: signed HTTP request security
MCP server signed HTTP request security — HMAC-SHA256, ed25519, replay prevention
TLS encrypts the channel between your MCP server and an upstream API, but it does not prove that the request was generated by your specific server instance, that the request body has not been tampered with between generation and transmission, or that the request has not been captured and replayed by an attacker who obtained a copy of a legitimate request. HTTP request signing — computing a cryptographic signature over a canonical representation of the request headers and body — provides these guarantees on top of TLS. This page covers five request signing patterns for MCP servers: HMAC-SHA256 over a canonical request string, ed25519 asymmetric key signatures, nonce-based replay prevention, clock skew tolerance with timestamp windows, and key rotation without downtime using versioned key IDs.
1. HMAC-SHA256 over a canonical request string
The fundamental weakness in unsigned HTTP requests is that the server receiving the request cannot verify whether the headers and body it received are identical to what the caller sent. A MITM proxy, a caching intermediary, or a rogue CDN edge node can modify any header or body field after TLS terminates. Request signing solves this: the sender computes a signature over a deterministic serialization of the request; the receiver re-computes the same serialization and verifies the signature. Any modification of the request in transit causes signature verification to fail.
The canonical request string must include every field that the receiver should treat as integrity-protected. Omitting a field from the canonical string means that field can be modified in transit without invalidating the signature. A common mistake is signing only the Authorization and Content-Type headers while leaving Content-Length unsigned — an attacker who can truncate the body will produce a body that matches the signed content hash at the truncation point if the length check is not also signed.
import { createHmac, createHash, timingSafeEqual } from 'node:crypto'
// DANGEROUS: unsigned request — body and headers can be modified in transit
async function callUpstreamDangerous(apiKey: string, path: string, body: object): Promise<Response> {
return fetch(`https://api.example.com${path}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
}
// SAFE: HMAC-SHA256 over canonical request string
// The signature covers method, host, path, timestamp, content-hash.
// Any modification of these fields after signing produces a bad signature.
function buildCanonicalRequest(
method: string,
host: string,
path: string,
timestamp: string,
bodyHex: string,
signedHeaders: Record<string, string>,
): string {
// Sort header names deterministically so both sides produce identical strings
const sortedHeaders = Object.entries(signedHeaders)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k.toLowerCase()}:${v.trim()}`)
.join('\n')
return [method.toUpperCase(), host.toLowerCase(), path, timestamp, bodyHex, sortedHeaders].join('\n')
}
async function callUpstreamSigned(
secretKey: Buffer,
keyId: string,
path: string,
body: object,
): Promise<Response> {
const host = 'api.example.com'
const method = 'POST'
const timestamp = Math.floor(Date.now() / 1000).toString()
const nonce = crypto.randomUUID()
const bodyBytes = Buffer.from(JSON.stringify(body))
const bodyHex = createHash('sha256').update(bodyBytes).digest('hex')
const signedHeaders = {
'content-type': 'application/json',
'x-timestamp': timestamp,
'x-nonce': nonce,
'x-key-id': keyId,
}
const canonical = buildCanonicalRequest(method, host, path, timestamp, bodyHex, signedHeaders)
const signature = createHmac('sha256', secretKey).update(canonical).digest('hex')
return fetch(`https://${host}${path}`, {
method,
headers: {
...signedHeaders,
'content-length': bodyBytes.length.toString(),
'x-signature': `hmac-sha256 ${signature}`,
},
body: bodyBytes,
})
}
2. Ed25519 asymmetric key signatures
HMAC-based request signing requires the upstream server to share the same secret key as the calling MCP server. If the upstream server is a third-party service or an internal service operated by a different team, sharing a symmetric secret creates key management complexity — every service that needs to verify requests must hold a copy of the secret, and a compromise of any one holder compromises all of them. Ed25519 asymmetric signatures solve this: the MCP server holds the private key and signs requests; the upstream server holds only the corresponding public key and verifies signatures. The private key never leaves the MCP server's trust boundary.
import { generateKeyPairSync, sign as cryptoSign, verify as cryptoVerify } from 'node:crypto'
// Key generation (run once, store private key in env/vault)
function generateEd25519KeyPair(): { publicKey: string; privateKey: string } {
const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
})
return { publicKey, privateKey }
}
// Signing: called by the MCP server before each outbound request
function signRequest(
privateKeyPem: string,
canonicalRequest: string,
): string {
const signature = cryptoSign(
null, // ed25519 uses null as algorithm — the key type implies SHA-512 internally
Buffer.from(canonicalRequest, 'utf8'),
privateKeyPem,
)
return signature.toString('base64url')
}
// Verification: called by the upstream server on receipt
// The upstream server only stores the public key — the private key is never shared
function verifyRequest(
publicKeyPem: string,
canonicalRequest: string,
signatureBase64url: string,
): boolean {
try {
return cryptoVerify(
null,
Buffer.from(canonicalRequest, 'utf8'),
publicKeyPem,
Buffer.from(signatureBase64url, 'base64url'),
)
} catch {
return false // malformed signature bytes
}
}
// The upstream server's verification handler
// (shown here as an Express middleware, adapt as needed)
function verifySignatureMiddleware(publicKeyPem: string) {
return (req: any, res: any, next: any) => {
const signature = req.headers['x-signature']?.replace('ed25519 ', '')
const timestamp = req.headers['x-timestamp']
if (!signature || !timestamp) return res.status(401).json({ error: 'Missing signature' })
// Reject timestamps outside ±5 minute window
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (age > 300) return res.status(401).json({ error: 'Request timestamp expired' })
const canonical = buildCanonicalRequest(
req.method, req.hostname, req.path, timestamp,
createHash('sha256').update(req.rawBody ?? '').digest('hex'),
{ 'content-type': req.headers['content-type'] ?? '', 'x-timestamp': timestamp },
)
if (!verifyRequest(publicKeyPem, canonical, signature)) {
return res.status(401).json({ error: 'Invalid signature' })
}
next()
}
}
3. Nonce-based replay prevention
A timestamp window alone limits replay attacks to a 5–10 minute window — long enough for an attacker who captures a request to replay it multiple times before the timestamp expires. Nonces prevent replay entirely within the validity window: the sender includes a unique random value in every request as a signed header; the receiver stores nonces for the validity window and rejects any request whose nonce has already been seen.
// Nonce cache on the upstream server (Redis-backed for multi-instance deployments)
// TTL = validity window (300 seconds) + clock skew buffer (60 seconds)
import { createClient } from 'redis'
const redis = createClient({ url: process.env.REDIS_URL })
const NONCE_TTL_SECONDS = 360 // 5 min window + 1 min buffer
async function checkAndRecordNonce(nonce: string): Promise<boolean> {
const key = `mcp:nonce:${nonce}`
// SET key 1 NX EX ttl — atomic set-if-not-exists with TTL
const result = await redis.set(key, '1', { NX: true, EX: NONCE_TTL_SECONDS })
// Returns 'OK' if the nonce was new (SET succeeded), null if the key existed (replay)
return result === 'OK'
}
// In the verification middleware
async function verifyNonce(req: any, res: any, next: any) {
const nonce = req.headers['x-nonce']
if (!nonce || typeof nonce !== 'string' || nonce.length < 16) {
return res.status(401).json({ error: 'Missing or invalid nonce' })
}
const isNew = await checkAndRecordNonce(nonce)
if (!isNew) {
// Nonce has been seen before — this is a replay
return res.status(409).json({ error: 'Nonce already used — replay detected' })
}
next()
}
// Sender: generate a fresh nonce per request
// crypto.randomUUID() produces a 36-character UUIDv4 — unique with overwhelming probability
function generateNonce(): string {
return crypto.randomUUID()
}
4. Clock skew tolerance and timestamp validation
Strict timestamp validation is essential for replay prevention but causes false rejections when the MCP server's clock drifts from the upstream server's clock. NTP synchronization is not perfectly reliable in containerized environments, and a 60-second clock drift is not unusual. A ±5 minute tolerance window is the industry standard — it is narrow enough to limit replay windows while accommodating realistic clock drift. The tolerance is asymmetric: you should reject requests with future timestamps more strictly than past timestamps, because a future timestamp indicates either clock drift or an attempt to extend the replay window.
const MAX_PAST_SECONDS = 300 // 5 minutes past
const MAX_FUTURE_SECONDS = 60 // 1 minute future (tighter — future timestamps extend replay window)
function validateTimestamp(timestampHeader: string): { valid: boolean; reason?: string } {
const timestamp = parseInt(timestampHeader, 10)
if (isNaN(timestamp)) return { valid: false, reason: 'Non-numeric timestamp' }
const nowSeconds = Math.floor(Date.now() / 1000)
const agePast = nowSeconds - timestamp // positive = request is in the past
const ageFuture = timestamp - nowSeconds // positive = request claims to be in the future
if (agePast > MAX_PAST_SECONDS) {
return { valid: false, reason: `Request timestamp too old: ${agePast}s past the window` }
}
if (ageFuture > MAX_FUTURE_SECONDS) {
// Future timestamp — could be clock drift or an attempt to extend replay window
return { valid: false, reason: `Request timestamp too far in future: ${ageFuture}s` }
}
return { valid: true }
}
// Sender: always use the current epoch second, not cached
function getCurrentTimestamp(): string {
return Math.floor(Date.now() / 1000).toString()
}
// Re-compute per request — do not reuse a timestamp across multiple requests
// (reusing a timestamp with a new nonce is still technically valid, but inadvisable)
5. Key rotation without downtime using versioned key IDs
Request signing is only as strong as the secrecy of the signing key. Keys must be rotatable without causing a window of failed requests during the transition. The mechanism is a key-id header included in the signed set: the upstream server maintains a map of key-id → public key and looks up the key used to sign a given request by its ID. When rotating keys, the MCP server begins signing with the new key and new ID; the upstream server accepts both old and new IDs for a grace period, then removes the old key after all in-flight requests have completed.
// Upstream server: key registry
// Load from a secrets manager; reload on SIGHUP for zero-downtime rotation
interface KeyRecord {
publicKeyPem: string
validFrom: Date
validUntil: Date | null // null = no expiry (active key)
}
const keyRegistry = new Map<string, KeyRecord>()
function registerKey(keyId: string, publicKeyPem: string, validFrom = new Date(), validUntil: Date | null = null) {
keyRegistry.set(keyId, { publicKeyPem, validFrom, validUntil })
}
function resolveKey(keyId: string): string | null {
const record = keyRegistry.get(keyId)
if (!record) return null
const now = new Date()
if (now < record.validFrom) return null // key not yet active
if (record.validUntil && now > record.validUntil) return null // key expired
return record.publicKeyPem
}
// Key rotation sequence:
// 1. Generate new key pair
// 2. Register new public key in upstream registry (via secrets manager API)
// 3. Update MCP server to sign with new private key + new key ID
// 4. After grace period (e.g., 15 minutes), set validUntil on old key to now
// 5. Old key is automatically rejected after its validUntil; no downtime during transition
// MCP server: sign with current key
function buildSignedHeaders(
privateKeyPem: string,
keyId: string,
method: string,
host: string,
path: string,
body: Buffer,
): Record<string, string> {
const timestamp = getCurrentTimestamp()
const nonce = generateNonce()
const bodyHex = createHash('sha256').update(body).digest('hex')
const signedHeaders = { 'content-type': 'application/json', 'x-timestamp': timestamp, 'x-nonce': nonce, 'x-key-id': keyId }
const canonical = buildCanonicalRequest(method, host, path, timestamp, bodyHex, signedHeaders)
const signature = signRequest(privateKeyPem, canonical)
return { ...signedHeaders, 'x-signature': `ed25519 ${signature}` }
}
What SkillAudit checks
SkillAudit's static analysis and LLM-assisted review flags these patterns in outbound request code:
- Unsigned outbound requests with credential headers —
fetch(url, { headers: { Authorization: ... } })without any signature header; the request body and headers are not integrity-protected beyond TLS. - HMAC over a non-canonical string — signing
JSON.stringify(body)directly rather than over a deterministic canonical form; different JSON serializers may produce different key orderings, causing spurious verification failures or excluding fields from the signed set. - Missing timestamp or nonce in signed headers — a signature without a timestamp or nonce can be replayed indefinitely.
- Symmetric HMAC key loaded from module-level const — the key is accessible to any code that can read module scope; it should be loaded via a credential function with TTL-based rotation, not a startup
const. - String comparison of signatures —
signature === expectedSignatureis vulnerable to timing attacks; usetimingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature)).
Run a free SkillAudit scan to check your MCP server's outbound request signing posture. The Security sub-score covers request forgery and replay attack surfaces, and the full report includes the file and line number of each finding.