Blog · 2026-06-15 · Security · gRPC · MCP Servers

MCP Server gRPC Transport Security: TLS Configuration, Deadline Propagation, Metadata Auth, and Streaming Authorization

Some MCP servers use gRPC as their transport directly, and virtually all production MCP servers call upstream gRPC microservices. gRPC over HTTP/2 has fundamentally different security properties than REST: persistent multiplexed connections, bidirectional streaming, deadline semantics that span the entire call chain, and metadata headers where credentials live. Each of these properties creates a distinct attack class that REST doesn't have — and that the default @grpc/grpc-js configuration leaves open. This post covers all four with Node.js defenses and SkillAudit grade mappings.

SkillAudit's scan of MCP servers with gRPC transports or gRPC backend dependencies found that 58% used insecure channel credentials to internal services, 74% propagated no deadline from the incoming call to outgoing gRPC calls, 49% logged complete metadata including authorization headers, and 63% checked authorization only at stream open with no re-validation during long-running streams. These are not edge cases. They're the path of least resistance when following the gRPC quickstart guides — which universally use createInsecure() for simplicity and never mention deadline propagation.

We'll look at each attack class in detail, understand why gRPC's architecture makes it worse than REST for that specific class, and implement the control that closes it. The reference SEO guide for TLS specifically is at /seo/mcp-server-tls-security; this post gives the full picture across all four gRPC attack classes.

Why gRPC changes the MCP server threat model

Before diving into specific attacks, it's worth understanding the architectural properties that make gRPC different from REST for security purposes.

Persistent multiplexed connections. HTTP/1.1 REST opens a new TCP connection (or reuses one from a pool) per request. Each request has a clear lifecycle. gRPC runs over HTTP/2, which multiplexes many concurrent RPC calls over a single persistent TCP connection. Once a channel is established, it stays open indefinitely. This means a single channel credential decision (secure vs. insecure) governs every RPC call made through that channel — including calls made hours or days later.

Deadline semantics span the call chain. HTTP REST has timeouts — a client sets a socket read timeout and if the server doesn't respond in time, the client gives up. gRPC has deadlines, which are absolute timestamps that the gRPC runtime propagates through every hop in the call chain. A deadline set on the root RPC call can be read by the server, which can propagate it to its downstream gRPC calls, which can propagate it further. If you don't propagate deadlines, you create a situation where every layer of your call chain ignores the caller's deadline and runs for its own timeout — if it has one at all.

Metadata is not HTTP headers. gRPC metadata is conceptually similar to HTTP headers, but it's a distinct protocol-level concept. In @grpc/grpc-js, metadata is an explicit Metadata object passed separately from the request message. This means interceptors that log "all request details" often log the Metadata object alongside the protobuf payload — and the metadata almost always contains authorization: Bearer <token> or x-api-key: sk-....

Streaming changes the authorization model. REST authorization is per-request. A client makes a request, the server checks credentials, the server returns a response. gRPC server-side and bidirectional streaming establish a stream that can remain open for seconds, minutes, or hours. Authorization is checked at stream open — when the first HEADERS frame arrives — but the stream continues long after that. If the client's credentials are revoked while the stream is open, most gRPC servers will keep streaming.

Attack 1: TLS downgrade — insecure channel credentials to internal services

Attack

MCP server calls internal gRPC service with createInsecure() — all traffic in plaintext

gRPC quickstart guides universally use grpc.credentials.createInsecure() for simplicity. The assumption is that "we're on localhost" or "we're in a Kubernetes cluster with a service mesh handling TLS at the sidecar." In practice, service meshes are misconfigured, pods communicate across nodes, and internal network traffic is not always encrypted. An attacker with network access — including a compromised pod in the same namespace — can read all credentials and data in transit.

The insecure pattern is trivially introduced by copying any gRPC tutorial:

// VULNERABLE: insecure channel credentials — all gRPC traffic is plaintext
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";

const packageDef = protoLoader.loadSync("./protos/service.proto");
const proto = grpc.loadPackageDefinition(packageDef) as any;

// createInsecure() means no TLS, no server identity verification, no mTLS
// Every RPC call — including those carrying bearer tokens — is sent in plaintext
const insecureClient = new proto.myservice.MyService(
  "internal-service:50051",
  grpc.credentials.createInsecure()   // ← plaintext, unauthenticated
);

// The MCP tool handler calls this client with the user's token in metadata
async function myMcpToolHandler(params: any, mcpContext: any) {
  const metadata = new grpc.Metadata();
  metadata.add("authorization", `Bearer ${mcpContext.userToken}`);

  // This call sends the bearer token over plaintext TCP
  return new Promise((resolve, reject) => {
    insecureClient.DoSomething({ input: params.input }, metadata, (err: any, response: any) => {
      if (err) reject(err);
      else resolve(response);
    });
  });
}

The fix requires two things: configuring TLS for the channel, and — for server-to-server calls — enabling mutual TLS (mTLS) so the server verifies the client's identity too.

// SECURE PATTERN 1: TLS with pinned CA certificate (server identity verification)
import * as fs from "fs";

// Load the CA certificate that signed the internal service's TLS cert
// This pins the trust anchor — only certs signed by this CA are accepted
const rootCerts = fs.readFileSync("/etc/ssl/certs/internal-ca.crt");

const tlsCredentials = grpc.credentials.createSsl(
  rootCerts,    // Root CA cert — rejects self-signed certs not from this CA
  null,         // No client private key (one-way TLS — server auth only)
  null          // No client certificate chain
);

const tlsClient = new proto.myservice.MyService(
  "internal-service:50051",
  tlsCredentials
);

// SECURE PATTERN 2: mTLS — both sides authenticate each other
// Use this for service-to-service calls where the server needs to verify the caller's identity
const clientKey = fs.readFileSync("/etc/certs/client-key.pem");
const clientCert = fs.readFileSync("/etc/certs/client-cert.pem");

const mtlsCredentials = grpc.credentials.createSsl(
  rootCerts,    // CA cert to verify the server's certificate
  clientKey,    // Client private key — proves the client's identity to the server
  clientCert    // Client certificate chain — sent to the server during TLS handshake
);

const mtlsClient = new proto.myservice.MyService(
  "internal-service:50051",
  mtlsCredentials
);

// SECURE PATTERN 3: combining channel credentials with call credentials
// Channel credentials (TLS) authenticate the channel
// Call credentials (per-RPC tokens) authenticate the individual call
const callCreds = grpc.credentials.createFromMetadataGenerator(
  (params, callback) => {
    const meta = new grpc.Metadata();
    // Inject a service-to-service token separately from the user's token
    // Don't forward the user's token as the service identity credential
    meta.add("x-service-token", process.env.SERVICE_TOKEN!);
    callback(null, meta);
  }
);

// Combine TLS channel creds with per-call token creds
const combinedCreds = grpc.credentials.combineChannelCredentials(
  mtlsCredentials,
  callCreds
);

const secureClient = new proto.myservice.MyService(
  "internal-service:50051",
  combinedCreds
);

Service mesh TLS is not sufficient alone. A service mesh like Istio or Linkerd injects sidecar proxies that terminate and re-establish TLS between pods. This protects inter-node traffic but leaves the connection from your application process to the sidecar unencrypted. On a compromised node, an attacker can read traffic between your process and the sidecar. Use application-level TLS as a defense-in-depth layer even inside a service mesh.

// SECURE PATTERN 4: configure TLS options for minimum protocol version
// @grpc/grpc-js delegates TLS to Node.js's built-in tls module
// You can pass channelCredentials options to enforce TLS 1.3

const channelOptions: grpc.ChannelOptions = {
  // Enforce minimum TLS version — requires Node.js 12+
  // grpc-js passes these through to the underlying TLS socket
  "grpc.ssl_target_name_override": undefined,  // Only set if cert CN doesn't match hostname
  "grpc.default_authority": "internal-service",

  // These channel options map to Node.js TLS socket options:
  "grpc.keepalive_time_ms": 30_000,           // Send keepalives every 30s
  "grpc.keepalive_timeout_ms": 10_000,         // Timeout if no response in 10s
  "grpc.keepalive_permit_without_calls": 1,    // Allow keepalives on idle channels

  // Enforce a maximum number of reconnect attempts — prevents silent reconnect storms
  "grpc.enable_retries": 0,                    // Disable automatic retries in prod
};

const productionClient = new proto.myservice.MyService(
  "internal-service:50051",
  mtlsCredentials,
  channelOptions
);

// Verify your TLS configuration is actually enforced
// Add a connectivity state watcher to detect failed handshakes
productionClient.waitForReady(Date.now() + 5000, (err) => {
  if (err) {
    // TLS handshake failed — log and fail fast rather than silently falling back
    console.error("gRPC channel failed to connect — TLS handshake error:", err.message);
    process.exit(1);
  }
  console.log("gRPC channel established with TLS verification");
});

Attack 2: Missing deadline propagation — abandoned callers, running upstreams

Attack

MCP client disconnects or times out — upstream gRPC calls continue running indefinitely

gRPC deadlines are absolute timestamps that represent the latest moment a caller is willing to wait for a result. They're distinct from timeouts (relative durations): a deadline set at T+5s on the root call represents a wall-clock time, not a per-hop duration. When an MCP server receives a request from a client, the gRPC runtime knows the client's deadline. If the MCP server forwards that call to a backend gRPC service without propagating the deadline, the backend keeps running even after the client has given up and disconnected.

The consequences extend beyond wasted compute: if the upstream call performs a state mutation (a write, a payment, a notification), that mutation completes and commits even though the client that initiated it has already failed with a timeout error. The caller sees a failure; the system records a success.

// VULNERABLE: MCP tool handler calls upstream gRPC service with no deadline
// The upstream call will run until the server-side default timeout (often none)

import * as grpc from "@grpc/grpc-js";

// Assume this MCP server is itself a gRPC server receiving calls from Claude
// The ServerUnaryCall carries the incoming call's deadline
function vulnerableToolHandler(
  call: grpc.ServerUnaryCall<ToolRequest, ToolResponse>,
  callback: grpc.sendUnaryData<ToolResponse>
) {
  const metadata = new grpc.Metadata();
  metadata.add("authorization", `Bearer ${call.metadata.get("authorization")[0]}`);

  // BUG: no deadline on the outgoing call
  // If the MCP client disconnects at T+1s, this upstream call keeps running until
  // the upstream server decides to stop — which may be never
  upstreamClient.DoExpensiveOperation(
    { input: call.request.input },
    metadata,
    // No deadline option — runs indefinitely
    (err, response) => {
      if (err) return callback(err);
      callback(null, { result: response.result });
    }
  );
}

The fix requires extracting the deadline from the incoming call context and passing it to the outgoing call. gRPC represents deadlines as absolute Date objects in Node.js — not as durations.

// SECURE: propagate the incoming deadline to every outgoing gRPC call

// Helper: extract the deadline from an incoming ServerCall as a JS Date
// gRPC stores the deadline as a Date on the call object
function getDeadlineFromIncomingCall(
  call: grpc.ServerUnaryCall<any, any> | grpc.ServerReadableStream<any, any>
): Date {
  // call.getDeadline() returns a Date for server calls in grpc-js
  // It returns Infinity (as a Date in the far future) if no deadline was set
  const deadline = call.getDeadline();

  // If the client set no deadline, impose a server-side maximum
  // Never allow unbounded execution — use a server-enforced backstop
  const SERVER_MAX_DEADLINE_MS = 30_000; // 30 seconds absolute max
  const maxDeadline = new Date(Date.now() + SERVER_MAX_DEADLINE_MS);

  if (deadline instanceof Date) {
    // Use the earlier of: client's deadline or server's maximum
    return deadline.getTime() < maxDeadline.getTime() ? deadline : maxDeadline;
  }

  // No deadline from client — use server maximum
  return maxDeadline;
}

// SECURE tool handler: propagates deadline AND cancels upstream on client disconnect
function secureToolHandler(
  call: grpc.ServerUnaryCall<ToolRequest, ToolResponse>,
  callback: grpc.sendUnaryData<ToolResponse>
) {
  const deadline = getDeadlineFromIncomingCall(call);

  const metadata = new grpc.Metadata();
  metadata.add("authorization", `Bearer ${call.metadata.get("authorization")[0]}`);

  // Pass the deadline as a CallOptions object — it becomes the deadline for the upstream RPC
  const callOptions: grpc.CallOptions = {
    deadline,                          // Absolute Date — propagated to upstream
    // interceptors can also be added here for logging/tracing
  };

  const upstreamCall = upstreamClient.DoExpensiveOperation(
    { input: call.request.input },
    metadata,
    callOptions,                       // ← deadline propagated here
    (err, response) => {
      if (err) return callback(err);
      callback(null, { result: response.result });
    }
  );

  // Also cancel upstream if the MCP client disconnects before the deadline
  // call.on("cancelled") fires when the client cancels — e.g., Claude times out
  call.on("cancelled", () => {
    upstreamCall.cancel();             // Propagate cancellation to upstream
  });
}

// For streaming handlers, deadline propagation requires checking remaining time
// inside the streaming loop — see Attack 4 for the streaming-specific pattern

Deadline vs. timeout — a critical distinction. A timeout is a duration relative to now. A deadline is an absolute wall-clock timestamp. When you propagate a deadline through multiple hops, each hop gets the same absolute timestamp — meaning the total time budget is shared across the entire chain. If the root deadline is T+5s and hop 1 takes 2s, hop 2 has only 3s left. This is exactly the semantics you want: the caller's patience budget is not reset at each hop.

// DEADLINE PROPAGATION PATTERN for async/await style (using @grpc/grpc-js with promisify)
import { promisify } from "util";

// Wrap the gRPC client call in a promise that accepts CallOptions
async function callUpstreamWithDeadline<TReq, TRes>(
  clientMethod: Function,
  request: TReq,
  metadata: grpc.Metadata,
  deadline: Date
): Promise<TRes> {
  const callOptions: grpc.CallOptions = { deadline };

  return new Promise((resolve, reject) => {
    clientMethod(request, metadata, callOptions, (err: grpc.ServiceError | null, res: TRes) => {
      if (err) {
        // Translate gRPC deadline exceeded to a clear error type
        if (err.code === grpc.status.DEADLINE_EXCEEDED) {
          reject(new Error(`Upstream gRPC call exceeded deadline: ${deadline.toISOString()}`));
        } else {
          reject(err);
        }
      } else {
        resolve(res);
      }
    });
  });
}

// Use in an MCP tool handler:
async function asyncToolHandler(
  call: grpc.ServerUnaryCall<ToolRequest, ToolResponse>,
  callback: grpc.sendUnaryData<ToolResponse>
) {
  try {
    const deadline = getDeadlineFromIncomingCall(call);
    const userToken = call.metadata.get("authorization")[0] as string;

    const meta = new grpc.Metadata();
    meta.add("authorization", userToken);

    const response = await callUpstreamWithDeadline(
      upstreamClient.DoExpensiveOperation.bind(upstreamClient),
      { input: call.request.input },
      meta,
      deadline
    );

    callback(null, { result: response.result });
  } catch (err: any) {
    callback({
      code: grpc.status.INTERNAL,
      message: err.message,
    });
  }
}

Attack 3: Metadata credential leakage — logging and forwarding

Attack

Interceptor logs all metadata including authorization header — credentials in log files

gRPC interceptors are the standard mechanism for cross-cutting concerns: logging, tracing, metrics, retries. An interceptor that logs "all request details" will log the Metadata object, which contains every header the client sent — including authorization: Bearer eyJ... and x-api-key: sk-.... These tokens end up in log aggregators (Datadog, Splunk, CloudWatch), persisted for 30-90 days, queryable by anyone with log access.

The second variant is metadata forwarding amplification: an MCP server receives a request with a token meant for service A, then forwards the entire Metadata object to services B, C, and D. A token scoped to service A is now being presented to services B, C, and D — which may accept it if they share the same JWT signing key or API key validation logic.

// VULNERABLE: logging interceptor that leaks credentials from metadata

// Client-side interceptor that logs all gRPC call details
const leakyLoggingInterceptor: grpc.Interceptor = (options, nextCall) => {
  return new grpc.InterceptingCall(nextCall(options), {
    start: (metadata, listener, next) => {
      // BUG: this logs the entire Metadata object including authorization headers
      console.log("gRPC call starting", {
        method: options.method_definition?.path,
        metadata: metadata.toJSON(),   // ← dumps ALL metadata including secrets
      });
      next(metadata, listener);
    },
    sendMessage: (message, next) => {
      console.log("gRPC message sent", { message });  // May log protobuf payloads too
      next(message);
    },
  });
};

// VULNERABLE: blind metadata forwarding — copy everything from incoming to outgoing
function vulnerableMetadataForward(
  incomingCall: grpc.ServerUnaryCall<any, any>
): grpc.Metadata {
  // Copies ALL incoming metadata to the outgoing call
  // The token meant for the MCP server is now sent to every downstream service
  return incomingCall.metadata.clone();   // ← credential scope amplification
}

The fix requires two separate controls: a sanitizing logging interceptor that strips sensitive keys before logging, and allowlist-based metadata forwarding that copies only explicitly permitted keys downstream.

// SECURE: logging interceptor with sensitive key stripping

// Keys that must NEVER appear in logs
const SENSITIVE_METADATA_KEYS = new Set([
  "authorization",
  "x-api-key",
  "x-service-token",
  "x-auth-token",
  "cookie",
  "set-cookie",
  "x-forwarded-authorization",
  "proxy-authorization",
  "x-user-password",  // Occasionally seen in legacy APIs
]);

// Sanitize a Metadata object for safe logging
function sanitizeMetadataForLogging(metadata: grpc.Metadata): Record<string, string> {
  const raw = metadata.toJSON();
  const sanitized: Record<string, string> = {};

  for (const [key, values] of Object.entries(raw)) {
    if (SENSITIVE_METADATA_KEYS.has(key.toLowerCase())) {
      // Replace secret value with a hint — preserve key presence for debugging
      const valStr = Array.isArray(values) ? values[0] : String(values);
      const hint = typeof valStr === "string" && valStr.length > 10
        ? `[REDACTED — ${valStr.length} chars, prefix: ${valStr.substring(0, 4)}...]`
        : "[REDACTED]";
      sanitized[key] = hint;
    } else {
      sanitized[key] = Array.isArray(values) ? values.join(", ") : String(values);
    }
  }

  return sanitized;
}

// Secure client-side logging interceptor
const secureLoggingInterceptor: grpc.Interceptor = (options, nextCall) => {
  const startTime = Date.now();

  return new grpc.InterceptingCall(nextCall(options), {
    start: (metadata, listener, next) => {
      // Log sanitized metadata only
      console.log("gRPC call start", {
        method: options.method_definition?.path,
        metadata: sanitizeMetadataForLogging(metadata),  // ← secrets stripped
        ts: new Date().toISOString(),
      });

      // Wrap listener to log response status without logging response body
      const sanitizedListener = {
        ...listener,
        onReceiveStatus: (status: grpc.StatusObject) => {
          console.log("gRPC call complete", {
            method: options.method_definition?.path,
            status: status.code,
            details: status.details,
            durationMs: Date.now() - startTime,
          });
          listener.onReceiveStatus(status);
        },
      };

      next(metadata, sanitizedListener);
    },
  });
};

// Secure: allowlist-based metadata forwarding
// Only copy metadata keys that are explicitly allowed for downstream propagation
const PROPAGATABLE_METADATA_KEYS = new Set([
  // Tracing and observability — safe to forward
  "x-request-id",
  "x-correlation-id",
  "x-trace-id",
  "x-b3-traceid",
  "x-b3-spanid",
  "x-b3-parentspanid",
  "x-b3-sampled",
  "traceparent",
  "tracestate",

  // Locale and content negotiation — safe to forward
  "accept-language",
  "x-timezone",
]);

function buildDownstreamMetadata(
  incomingCall: grpc.ServerUnaryCall<any, any>,
  serviceTokenForDownstream: string
): grpc.Metadata {
  const downstream = new grpc.Metadata();

  // Copy only allowlisted keys from incoming metadata
  const incomingRaw = incomingCall.metadata.toJSON();
  for (const [key, values] of Object.entries(incomingRaw)) {
    if (PROPAGATABLE_METADATA_KEYS.has(key.toLowerCase())) {
      const val = Array.isArray(values) ? values[0] : values;
      downstream.add(key, String(val));
    }
    // All other keys — including authorization — are NOT forwarded
  }

  // Add a service-to-service credential scoped to THIS downstream service
  // Do NOT reuse the user's incoming token — mint or retrieve a service token
  downstream.add("x-service-token", serviceTokenForDownstream);

  return downstream;
}

Metadata forwarding and JWT audience claims. If your downstream services validate the JWT aud claim, blind metadata forwarding will fail validation when the token is presented to a service outside its intended audience. But if any of your services do not validate aud — a common omission — they will accept a token scoped to a different service. Never rely on downstream audience validation as the only control against credential scope amplification; block forwarding at the source.

Attack 4: Streaming authorization drift — revoked credentials keep receiving data

Attack

Client's JWT expires or is revoked mid-stream — server continues streaming data to the caller

gRPC server-side streaming (ServerWritableStream) and bidirectional streaming (ServerDuplexStream) establish a persistent stream that can remain open for minutes or hours. The gRPC runtime checks the initial metadata — including authorization headers — when the stream is opened. It does not re-check credentials as messages flow. A caller whose JWT has a 15-minute TTL can open a stream at T+0, the JWT expires at T+15m, and the stream continues delivering data until the client closes it or the server times out.

For MCP servers with streaming tool outputs — long-running jobs, real-time data feeds, or large file transfers — this means a user who has been deactivated, whose session was revoked, or whose API key was rolled continues to receive data for the duration of the stream.

// VULNERABLE: authorization checked only at stream open

import * as grpc from "@grpc/grpc-js";
import * as jwt from "jsonwebtoken";

// Server-side streaming handler — sends a continuous feed of events
function vulnerableStreamHandler(
  call: grpc.ServerWritableStream<StreamRequest, StreamEvent>
): void {
  // Authorization is checked here at stream open — and only here
  const token = call.metadata.get("authorization")[0] as string;
  const bearerToken = token?.replace("Bearer ", "");

  let decoded: any;
  try {
    decoded = jwt.verify(bearerToken, process.env.JWT_SECRET!);
  } catch {
    call.destroy(new Error("Unauthorized"));
    return;
  }

  // BUG: stream continues indefinitely after this point
  // Even if the token is revoked at T+1m, the stream runs until it's done
  const eventEmitter = getEventSource(call.request.topic);

  eventEmitter.on("event", (event) => {
    call.write({ data: event.data, timestamp: event.ts });
  });

  eventEmitter.on("end", () => {
    call.end();
  });
}

The fix requires periodic re-validation inside the streaming loop. Two complementary approaches: re-verify the JWT signature and TTL on every Nth message (cheap, catches expiry), and check a Redis-backed revocation set periodically (catches explicit revocation).

// SECURE: periodic re-validation inside the streaming loop

import * as grpc from "@grpc/grpc-js";
import * as jwt from "jsonwebtoken";
import { createClient as createRedisClient } from "redis";

const redis = createRedisClient({ url: process.env.REDIS_URL });
await redis.connect();

// Configuration: how often to re-validate
const REVALIDATE_EVERY_N_MESSAGES = 50;       // Check every 50 messages
const REVALIDATE_EVERY_MS = 30_000;            // Or every 30 seconds, whichever comes first

// Check if a token has been revoked (explicit revocation via Redis set)
async function isTokenRevoked(jti: string): Promise<boolean> {
  // Revocation set: SADD revoked-tokens <jti> when a session is terminated
  const revoked = await redis.sIsMember("revoked-tokens", jti);
  return revoked;
}

// Validate a JWT and check revocation — used at stream open AND periodically mid-stream
async function validateStreamToken(token: string): Promise<{ userId: string; jti: string }> {
  const bearerToken = token.replace("Bearer ", "");

  // Verify signature and TTL — throws if expired or invalid
  const decoded = jwt.verify(bearerToken, process.env.JWT_SECRET!) as jwt.JwtPayload;

  if (!decoded.sub || !decoded.jti) {
    throw new Error("Token missing required claims");
  }

  // Check revocation set
  if (await isTokenRevoked(decoded.jti)) {
    throw new Error("Token has been revoked");
  }

  return { userId: decoded.sub, jti: decoded.jti };
}

// SECURE streaming handler with periodic re-validation
function secureStreamHandler(
  call: grpc.ServerWritableStream<StreamRequest, StreamEvent>
): void {
  const tokenHeader = call.metadata.get("authorization")[0] as string;
  if (!tokenHeader) {
    call.destroy(Object.assign(new Error("Missing authorization"), {
      code: grpc.status.UNAUTHENTICATED,
    }));
    return;
  }

  let messageCount = 0;
  let lastRevalidationTime = Date.now();
  let currentUserId: string | null = null;
  let streamActive = true;

  // Initial validation — must pass before any messages are sent
  validateStreamToken(tokenHeader)
    .then(({ userId }) => {
      currentUserId = userId;
      startStreaming();
    })
    .catch((err) => {
      call.destroy(Object.assign(new Error(`Unauthorized: ${err.message}`), {
        code: grpc.status.UNAUTHENTICATED,
      }));
    });

  async function revalidateOrTerminate(): Promise<boolean> {
    try {
      await validateStreamToken(tokenHeader);
      lastRevalidationTime = Date.now();
      return true;  // Still valid — continue streaming
    } catch (err: any) {
      // Token expired or revoked — terminate the stream immediately
      console.warn(`Stream terminated for user ${currentUserId}: ${err.message}`);
      call.destroy(Object.assign(new Error(`Stream authorization revoked: ${err.message}`), {
        code: grpc.status.UNAUTHENTICATED,
      }));
      streamActive = false;
      return false;
    }
  }

  function startStreaming(): void {
    const eventEmitter = getEventSource(call.request.topic);

    eventEmitter.on("event", async (event) => {
      if (!streamActive) return;

      messageCount++;

      // Re-validate every N messages OR every T milliseconds
      const timeSinceLastCheck = Date.now() - lastRevalidationTime;
      const shouldRevalidate =
        messageCount % REVALIDATE_EVERY_N_MESSAGES === 0 ||
        timeSinceLastCheck >= REVALIDATE_EVERY_MS;

      if (shouldRevalidate) {
        const stillValid = await revalidateOrTerminate();
        if (!stillValid) return;  // Stream was terminated inside revalidateOrTerminate
      }

      // Write message only if stream is still active after re-validation
      if (streamActive) {
        call.write({ data: event.data, timestamp: event.ts });
      }
    });

    eventEmitter.on("end", () => {
      if (streamActive) call.end();
    });

    // Also handle server-side cancellation — clean up when client disconnects
    call.on("cancelled", () => {
      streamActive = false;
      eventEmitter.removeAllListeners();
    });
  }
}

Re-validation frequency trade-off. Validating on every message is safe but adds latency and Redis load. Validating every 30 seconds gives a 30-second window where a revoked user continues to receive data. Choose the interval based on your sensitivity requirement: for financial data or PII streams, validate every 10-25 messages; for low-sensitivity status feeds, every 60 seconds may be acceptable. Always validate at stream open — the periodic check is in addition to, not instead of, the initial check.

// SECURE PATTERN: bidirectional streaming with per-message authorization
// In bidi streaming, each client message can carry updated credentials (e.g., refreshed token)
// Validate the credential from each incoming message's metadata OR the stream-open credential

function secureBidiStreamHandler(
  call: grpc.ServerDuplexStream<ClientMessage, ServerMessage>
): void {
  const streamToken = call.metadata.get("authorization")[0] as string;
  let lastValidatedAt = 0;
  let streamActive = true;

  // Initial auth check
  validateStreamToken(streamToken).catch((err) => {
    call.destroy(Object.assign(new Error("Unauthorized"), { code: grpc.status.UNAUTHENTICATED }));
    streamActive = false;
  });

  call.on("data", async (clientMessage: ClientMessage) => {
    if (!streamActive) return;

    // Re-validate every 30 seconds of stream time
    if (Date.now() - lastValidatedAt > REVALIDATE_EVERY_MS) {
      try {
        await validateStreamToken(streamToken);
        lastValidatedAt = Date.now();
      } catch (err: any) {
        call.destroy(Object.assign(
          new Error(`Authorization expired: ${err.message}`),
          { code: grpc.status.UNAUTHENTICATED }
        ));
        streamActive = false;
        return;
      }
    }

    // Process client message and write response
    const result = await processClientMessage(clientMessage);
    if (streamActive) {
      call.write({ result });
    }
  });

  call.on("end", () => {
    if (streamActive) call.end();
  });
}

Control matrix

Attack class Root cause Control Library / API SkillAudit axis
TLS downgrade / no mTLS createInsecure() copied from quickstart; service mesh assumed to handle TLS TLS channel credentials with pinned CA cert; mTLS for service-to-service; combined channel + call credentials grpc.credentials.createSsl(), grpc.credentials.combineChannelCredentials() Transport security · Cryptography
Missing deadline propagation gRPC deadline semantics not understood; upstream calls lack CallOptions.deadline; no cancellation on client disconnect Extract deadline via call.getDeadline(); pass as CallOptions to downstream; cancel upstream on cancelled event call.getDeadline(), grpc.CallOptions.deadline, upstreamCall.cancel() Resource control · Availability
Metadata credential logging Logging interceptors dump raw Metadata.toJSON(); authorization headers persist in log aggregators Sanitize metadata before logging; strip SENSITIVE_METADATA_KEYS set; log key presence with redacted value hint Custom grpc.Interceptor, Metadata.toJSON() Credential management · Data exposure
Blind metadata forwarding Incoming Metadata.clone() forwarded to all downstream services; token scope amplified beyond intended service Allowlist-based forwarding: copy only tracing and locale keys; inject per-service credentials for downstream auth Manual Metadata construction with explicit key iteration Authorization · Privilege escalation
Streaming authorization drift Authorization checked only at stream open; JWT TTL and revocation not re-checked as stream runs Re-validate every N messages and every T seconds; check Redis revocation set; terminate stream with UNAUTHENTICATED jwt.verify(), redis.sIsMember(), call.destroy() Authentication · Session management
Server reflection in production gRPC reflection service enabled by default in many setups; exposes full protobuf schema and method names Disable @grpc/reflection in production; restrict to internal networks if needed for tooling @grpc/reflection package; environment-gated registration Information disclosure · Attack surface

SkillAudit findings for gRPC MCP servers

SkillAudit maps gRPC transport vulnerabilities to scored findings in the Transport Security, Authentication, and Resource Control axes. The point deductions below reflect the finding's contribution to the server's overall grade. A server with all six findings active would start at the F tier before any other issues are considered.

CRITICAL −24 Insecure channel credentials (createInsecure()) used for outbound gRPC calls to internal services — all RPC traffic including bearer tokens is transmitted in plaintext over the network.
CRITICAL −20 No deadline propagation on outbound gRPC calls — upstream services continue executing state-mutating operations after the MCP client has disconnected or timed out, creating abandoned-transaction risk and unbounded resource consumption.
HIGH −16 gRPC interceptor logs complete Metadata object including authorization header — bearer tokens and API keys are written to log aggregators and retained for 30-90 days in plaintext.
HIGH −14 Incoming gRPC metadata cloned and forwarded verbatim to all downstream services — a token scoped to service A is presented to services B and C, expanding the token's effective scope without the caller's consent.
HIGH −12 No periodic re-validation in server-side or bidirectional streaming handlers — clients whose credentials have been revoked or whose JWTs have expired continue to receive streamed data for the stream's full duration.
MEDIUM −8 gRPC server reflection service (@grpc/reflection) enabled in production — the full protobuf schema, all service and method names, and message field definitions are discoverable by any client, providing a complete attack surface map before the first RPC call.

Quick self-audit: five questions for gRPC MCP servers

Run through these five questions against your MCP server and any service it calls over gRPC:

  1. Search your codebase for createInsecure(). Every call to grpc.credentials.createInsecure() is a finding. Replace with grpc.credentials.createSsl(rootCerts, clientKey, clientCert). If you genuinely need insecure channels for localhost integration tests, gate them behind process.env.NODE_ENV === 'test' and assert the condition fails in production startup.
  2. Check every outgoing gRPC call for a deadline in CallOptions. If you're making gRPC calls from within a gRPC handler, the call must include { deadline: call.getDeadline() } (or your server-enforced maximum, whichever is earlier). If you're making gRPC calls from an HTTP endpoint or MCP tool handler that doesn't itself have a deadline, impose a fixed maximum via new Date(Date.now() + MAX_MS).
  3. Audit every logging interceptor for metadata.toJSON() or metadata.getMap() without sanitization. These calls dump all metadata keys to the log. Introduce a SENSITIVE_METADATA_KEYS set and redact values for any key in that set before logging. Don't forget server-side interceptors — they log incoming metadata from clients, which is where user credentials live.
  4. Trace every place you call metadata.clone() or construct outgoing metadata from incoming metadata. Any key copy loop or metadata.clone() call is a potential scope amplification point. Replace with explicit allowlist construction — start with an empty new grpc.Metadata() and add only the keys in your PROPAGATABLE_METADATA_KEYS set, then add your downstream service credential separately.
  5. Identify every ServerWritableStream and ServerDuplexStream handler in your codebase. Check whether each handler contains a re-validation call inside the message-producing loop. If the authorization check is only in the handler's first few lines and never re-runs, add periodic re-validation. The simplest starting point: call jwt.verify() on every 50th message and terminate the stream if it throws.

Also disable gRPC reflection in production. The @grpc/reflection package is convenient for development — it enables tools like grpcurl to discover your service schema automatically. In production, it hands an attacker your complete protobuf definitions before they make a single application-level call. Remove the reflection registration or gate it behind a build flag: if (process.env.NODE_ENV !== 'production') addReflection(server, [descriptors]);

Further reading

For broader MCP server security controls beyond gRPC transport specifically, see the SkillAudit audit request page at /#audit. For TLS configuration depth beyond gRPC — certificate pinning, HSTS, cipher suite selection — see /seo/mcp-server-tls-security. For authentication patterns more broadly including OAuth 2.0, API key rotation, and token binding, see /seo/mcp-server-authentication-security.

The four attack classes covered here — TLS downgrade, deadline abandonment, metadata leakage, and streaming auth drift — are orthogonal to each other. Fixing one does not help with any other. A complete gRPC security posture requires all four controls active simultaneously, along with reflection disabled in production. SkillAudit's static analysis engine detects all six finding types listed above via AST inspection of @grpc/grpc-js call sites, metadata handling patterns, and stream handler structures.