MCP Server Security — HTTP/3 & QUIC

MCP server HTTP/3 and QUIC security — connection migration attacks, 0-RTT replay, and alt-svc hijacking

HTTP/3 builds on QUIC — a UDP-based transport that introduces performance features (0-RTT resumption, connection migration, multiplexed streams without head-of-line blocking) with security tradeoffs that HTTP/1.1 and HTTP/2 don't have. MCP servers adopting HTTP/3 for low-latency streaming tool responses gain those performance benefits while inheriting new attack surfaces: 0-RTT early data is replayable, connection migration can be exploited to reroute traffic, alt-svc headers that advertise HTTP/3 availability can be hijacked, and QUIC's UDP base enables amplification attacks against clients. This page covers each attack surface and the configuration patterns that mitigate them.

Attack 1: 0-RTT early data replay — non-idempotent tool calls replayed without detection

QUIC's 0-RTT mode allows a client that has previously connected to resume the session and send application data in the very first packet — before the server has completed its handshake validation. This eliminates one round-trip for returning clients. The security tradeoff: 0-RTT data is replayable. An on-path attacker who captures the 0-RTT data packet can replay it to a different server instance, or delay it and replay it later. For MCP servers with idempotent tool calls (reads, lookups) this is acceptable. For non-idempotent calls (create, update, delete, send), replay causes duplicate execution.

// QUIC server configuration with 0-RTT replay protection (using h3/Node.js)
// Note: Node.js native QUIC support is experimental; Caddy handles this at the edge

// Caddy configuration (production recommendation — let Caddy handle QUIC):
// {
//   servers {
//     protocols h1 h2 h3
//   }
// }
// mysite.example.com {
//   reverse_proxy localhost:3000
//   header {
//     # Advertise HTTP/3 only on HTTPS — never on HTTP
//     alt-svc "h3=\":443\"; ma=2592000"
//   }
// }

// Application-layer 0-RTT replay protection using idempotency keys
// (works regardless of transport layer)

import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });

app.post('/mcp/tools/:toolName', async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'];
  const isNonIdempotent = NON_IDEMPOTENT_TOOLS.has(req.params.toolName);

  if (isNonIdempotent) {
    if (!idempotencyKey) {
      return res.status(400).json({ error: 'Idempotency-Key header required for this tool' });
    }

    // SET NX with 24-hour expiry — first call wins, replays get cached response
    const cacheKey = `idem:${req.user.id}:${idempotencyKey}`;
    const existing = await redisClient.get(cacheKey);
    if (existing) {
      // Return cached response for replay — idempotent behavior
      return res.status(200).json(JSON.parse(existing));
    }

    // Mark as in-progress before executing (lock against concurrent replay)
    const acquired = await redisClient.set(cacheKey, JSON.stringify({ status: 'processing' }), {
      NX: true,
      EX: 86400,
    });
    if (!acquired) {
      return res.status(409).json({ error: 'Concurrent request with same idempotency key' });
    }
  }

  const result = await executeTool(req.params.toolName, req.body, req.user);

  if (isNonIdempotent && idempotencyKey) {
    await redisClient.set(`idem:${req.user.id}:${idempotencyKey}`, JSON.stringify(result), {
      EX: 86400,
    });
  }

  res.json(result);
});

Attack 2: Connection migration — authentication state bound to original IP, not connection ID

QUIC's connection migration allows a client to change its IP address (e.g. switching from WiFi to 4G) without dropping the connection. The connection is identified by a Connection ID (CID) rather than the IP:port tuple. MCP servers that bind authentication state to the client's source IP will incorrectly invalidate legitimate migrated connections — or worse, if they don't verify that migration is from a nearby network, an attacker who learns a CID can migrate a connection to a different IP, potentially hijacking the session.

// Application-layer connection migration security
// QUIC connection migration is handled at the transport layer (Caddy/nginx),
// but MCP servers can enforce additional constraints at the application layer.

// In your auth middleware, bind the session to a normalized network prefix
// rather than a specific IP — this allows legitimate migration (same ISP)
// while flagging suspicious migration (cross-country IP changes)

function getNetworkPrefix(ip) {
  // For IPv4: use /24 (last octet stripped)
  // For IPv6: use /48 (last 80 bits stripped)
  if (ip.includes(':')) {
    return ip.split(':').slice(0, 3).join(':') + '::'; // /48 prefix
  }
  return ip.split('.').slice(0, 3).join('.') + '.0'; // /24 prefix
}

app.use(async (req, res, next) => {
  const session = await getSession(req.headers['x-session-id']);
  if (!session) return res.status(401).end();

  const currentPrefix = getNetworkPrefix(req.ip);
  const originalPrefix = session.originalNetworkPrefix;

  if (originalPrefix && currentPrefix !== originalPrefix) {
    // IP migration detected — require re-authentication for sensitive tools
    req.requiresReauth = true;
    logger.warn({ sessionId: session.id, originalPrefix, currentPrefix }, 'QUIC connection migration detected');
  }

  if (!session.originalNetworkPrefix) {
    // First request — bind session to network prefix
    await updateSessionNetworkPrefix(session.id, currentPrefix);
  }

  req.user = session.user;
  next();
});

Attack 3: alt-svc hijacking — HTTP-delivered alt-svc redirects to malicious QUIC server

The alt-svc response header tells clients that the server is also available via a different protocol (HTTP/3) at a specified host and port. If an MCP server or any intermediary delivers an alt-svc header over plain HTTP (not HTTPS), an attacker who can inject HTTP headers (via MITM on HTTP, or via a compromised CDN edge) can redirect the client to a malicious QUIC listener on an attacker-controlled IP. The client will then send subsequent authenticated requests to the attacker's server.

// SECURE alt-svc configuration in Caddy (recommended)
// alt-svc should only be served on HTTPS, never on HTTP

// Caddy Caddyfile:
// (secure-headers) {
//   header {
//     # Strict-Transport-Security prevents HTTP downgrade
//     Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
//     # alt-svc only served under HTTPS (Caddy enforces this automatically for TLS sites)
//     alt-svc "h3=\":443\"; ma=86400"
//   }
// }

// In Express (if not using Caddy for HTTP/3):
app.use((req, res, next) => {
  // Never set alt-svc on HTTP requests
  if (req.secure) {
    res.setHeader('alt-svc', 'h3=":443"; ma=86400');
  }
  // Always set HSTS on HTTPS responses to prevent HTTP downgrade
  if (req.secure) {
    res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  }
  next();
});

Attack 4: QUIC amplification — server sends more data than it receives from unverified clients

QUIC's address validation mechanism bounds the amplification factor: before the client proves address ownership (by completing the handshake), the server limits its total response data to 3× the received data. But misconfigured servers (incorrect QUIC implementation settings, oversized Initial packets) can exceed this limit, enabling attackers to use a spoofed-source QUIC Initial packet to amplify traffic toward a victim. Most production MCP server deployments use Caddy or nginx as the QUIC terminator, which handles this correctly — but custom Node.js QUIC implementations must enforce the 3× rule explicitly.

// If implementing QUIC directly in Node.js (experimental as of Node 22):
// Ensure anti-amplification is enforced during Initial packet handling

// For most MCP servers: use Caddy as the QUIC/HTTP3 terminator
// and only run Node.js over HTTP/2 internally. This is the safe default.

// Caddy anti-amplification is handled by the quic-go library (Caddy's QUIC stack)
// and follows RFC 9000 Section 8.1 address validation requirements.

// Rate limiting at the UDP packet level (iptables/nftables, before your server process):
// nftables rule example — limit new QUIC connections per source IP:
// table inet filter {
//   chain input {
//     udp dport 443 meter quic-rate { ip saddr limit rate 500/second } accept
//     udp dport 443 drop
//   }
// }

// Application-layer: rate limit per connection ID to prevent connection ID exhaustion
const connectionCounts = new Map(); // sourceIP → count
const MAX_CONNECTIONS_PER_IP = 10;

quicServer.on('connection', (connection) => {
  const sourceIp = connection.remoteAddress;
  const count = (connectionCounts.get(sourceIp) ?? 0) + 1;
  if (count > MAX_CONNECTIONS_PER_IP) {
    connection.close({ code: 429, reason: 'Too many connections from this IP' });
    return;
  }
  connectionCounts.set(sourceIp, count);
  connection.on('close', () => {
    connectionCounts.set(sourceIp, Math.max(0, (connectionCounts.get(sourceIp) ?? 1) - 1));
  });
});

SkillAudit findings

The following findings appear in SkillAudit audit reports for MCP servers using HTTP/3 or QUIC:

CRITICAL  Non-idempotent tool endpoints accept 0-RTT without replay protection. Tool endpoints that create, update, or delete resources accept QUIC 0-RTT early data without application-layer idempotency keys. An on-path attacker who captures a 0-RTT early data packet can replay the tool call, causing duplicate creation, double-sends, or repeated destructive operations.

HIGH  alt-svc header served over HTTP — connection hijack via header injection. The server advertises HTTP/3 availability via alt-svc headers on plain HTTP responses. An attacker who can inject HTTP headers (MITM, compromised intermediary) can redirect clients to a malicious QUIC listener on an attacker-controlled host. Set alt-svc only on HTTPS responses and enforce HSTS.

HIGH  No connection migration re-authentication for cross-subnet IP changes. The server does not flag or require re-authentication when a QUIC connection migrates to an IP in a different network subnet. A session hijacker who learns a Connection ID can migrate the connection to a different IP without triggering any re-auth challenge.

MEDIUM  No per-IP connection limit for QUIC — connection ID exhaustion possible. The QUIC server imposes no limit on simultaneous connections per source IP. An attacker can open thousands of connections to exhaust the server's Connection ID tracking table or trigger amplification conditions in the QUIC handshake. Enforce per-IP connection limits at the network layer and application layer.

Paste a GitHub URL at skillaudit.dev to get a graded report card.