MCP Server Security — GraphQL Federation

MCP server GraphQL federation security — subgraph trust, cross-service auth, and query plan injection

GraphQL federation splits a single API into multiple subgraphs owned by different services, with a gateway that composes them into a unified schema and plans queries across them. In MCP servers this architecture introduces attack surfaces that don't exist in monolithic GraphQL: the gateway trusts subgraph responses unconditionally (opening query plan injection vectors), authorization context must be propagated across the service boundary (opening cross-service privilege escalation), and the subgraph's @key entity resolution can be abused to traverse authorization boundaries. This page covers the four patterns with Node.js Apollo Federation fixes.

Pattern 1: Subgraph trust — gateway passes auth token, subgraph accepts any caller

The federation gateway validates the caller's JWT and forwards it to subgraphs via a header (e.g. X-User-Id or a forwarded Authorization header). Subgraphs that only check for the presence of this header — rather than verifying it was sent by the gateway — can be called directly by any service or external attacker that knows the subgraph's internal URL. Subgraph-direct requests bypass the gateway's rate limiting, depth limiting, and authorization middleware entirely.

// VULNERABLE: subgraph accepts any X-User-Id header
// Any caller who knows the internal URL can impersonate any user
app.use('/graphql', (req, res, next) => {
  const userId = req.headers['x-user-id'];
  if (userId) {
    req.user = { id: userId }; // trusted without gateway verification
  }
  next();
});

// FIXED: subgraph verifies gateway HMAC signature on every request
import { createHmac, timingSafeEqual } from 'crypto';

const GATEWAY_SECRET = process.env.GATEWAY_SHARED_SECRET;

function verifyGatewayRequest(req) {
  const signature = req.headers['x-gateway-signature'];
  const timestamp = req.headers['x-gateway-timestamp'];
  const userId = req.headers['x-user-id'];

  if (!signature || !timestamp || !userId) return false;

  // Reject replayed requests older than 30 seconds
  const age = Date.now() - parseInt(timestamp, 10);
  if (age > 30_000 || age < 0) return false;

  const expected = createHmac('sha256', GATEWAY_SECRET)
    .update(`${timestamp}:${userId}`)
    .digest('hex');

  return timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex'),
  );
}

app.use('/graphql', (req, res, next) => {
  if (!verifyGatewayRequest(req)) {
    return res.status(401).json({ errors: [{ message: 'Invalid gateway signature' }] });
  }
  req.user = { id: req.headers['x-user-id'] };
  next();
});

Pattern 2: Cross-service authorization — entity resolution bypasses ownership checks

In Apollo Federation, the gateway resolves entity references by calling the owning subgraph's __resolveReference resolver with only the entity's primary key fields (e.g. { __typename: "Audit", id: "abc123" }). If the resolver fetches the entity by ID without verifying that the requesting user owns it, any authenticated user can retrieve any entity of that type by crafting a query that includes an entity reference they don't own.

// VULNERABLE: __resolveReference fetches by ID without ownership check
const resolvers = {
  Audit: {
    __resolveReference: async ({ id }) => {
      // No ownership check — returns any audit by ID
      return db.audits.findById(id);
    },
  },
};

// FIXED: __resolveReference checks ownership via context
// Context must carry the user identity forwarded from the gateway
const resolvers = {
  Audit: {
    __resolveReference: async ({ id }, context) => {
      const audit = await db.audits.findById(id);
      // Return null (not error) if not found or not owned
      // — same response prevents enumeration
      if (!audit || audit.userId !== context.user.id) {
        return null;
      }
      return audit;
    },
  },
};

// Ensure context carries user identity in the subgraph's context builder:
const server = new ApolloServer({
  schema: buildSubgraphSchema([{ typeDefs, resolvers }]),
  context: ({ req }) => ({
    user: { id: req.headers['x-user-id'] }, // verified by gateway auth middleware above
  }),
});

Pattern 3: Query plan injection via malicious subgraph response

The federation gateway merges subgraph responses to fulfill a single query. If a subgraph returns unexpected fields that the gateway didn't request — or crafted values in fields that the gateway uses for subsequent entity resolution — a compromised or misconfigured subgraph can influence downstream subgraph queries in the same request. This is the federation analogue of second-order injection: the subgraph's response shapes the gateway's behavior in subsequent planning steps.

// VULNERABLE: gateway merges subgraph response without field validation
// Malicious subgraph returns extra fields that contaminate the merged result
// or override expected fields with attacker-controlled values

// FIXED: validate subgraph responses against the known schema
import { parse, validate, buildASTSchema } from 'graphql';

const subgraphSchemas = new Map(); // subgraphName → schema

async function fetchAndValidateSubgraph(subgraphName, query, variables) {
  const response = await fetch(subgraphUrls.get(subgraphName), {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...gatewayHeaders() },
    body: JSON.stringify({ query, variables }),
  });
  const data = await response.json();

  // Strip any fields not present in the expected response shape
  // Use a JSON Schema validator against the known response structure
  const schema = subgraphSchemas.get(subgraphName);
  return stripUnknownFields(data, schema);
}

function stripUnknownFields(obj, schema) {
  if (!schema || typeof obj !== 'object' || obj === null) return obj;
  const allowed = new Set(Object.keys(schema.properties ?? {}));
  return Object.fromEntries(
    Object.entries(obj)
      .filter(([k]) => allowed.has(k))
      .map(([k, v]) => [k, stripUnknownFields(v, schema.properties?.[k])]),
  );
}

Pattern 4: Subgraph URL exposure — direct subgraph access bypasses gateway controls

Federation subgraphs are internal services intended to receive traffic only from the gateway. If subgraph URLs are discoverable (via DNS, service registry, or error messages), attackers can send queries directly to subgraphs, bypassing all gateway-level controls: rate limiting, depth limits, introspection blocking, and authentication middleware. Direct subgraph access also bypasses the gateway's query planning, allowing arbitrarily complex subgraph-native queries.

// FIXED: network isolation + allow-list enforcement in each subgraph
// 1. Network level: subgraphs only accept connections from gateway IP range
//    (Docker network, Kubernetes NetworkPolicy, or VPC security groups)

// 2. Application level: subgraph rejects requests without valid gateway signature
//    (see Pattern 1 above)

// 3. Disable introspection on subgraphs — only the gateway should expose the schema
const server = new ApolloServer({
  schema: buildSubgraphSchema([{ typeDefs, resolvers }]),
  introspection: false, // never expose subgraph schema externally
  plugins: [
    {
      async requestDidStart() {
        return {
          async didResolveOperation({ request }) {
            // Block __schema and __type queries at the subgraph level
            if (request.operationName === 'IntrospectionQuery') {
              throw new Error('Introspection not allowed on subgraphs');
            }
          },
        };
      },
    },
  ],
});

SkillAudit findings

The following findings appear in SkillAudit audit reports for MCP servers that use GraphQL federation:

CRITICAL  Subgraphs accept any gateway-forwarded identity header without signature verification. The federation subgraph trusts X-User-Id or forwarded Authorization headers without verifying they were signed by the gateway. Any service or external caller that can reach the subgraph's internal endpoint can impersonate any user by setting these headers, bypassing all gateway-level security controls.

CRITICAL  Entity __resolveReference fetches by ID without ownership check. The subgraph resolves entity references by ID without verifying that the requesting user owns the entity. Any authenticated user can retrieve any entity of that type by referencing its ID in a federated query, even if they have no relationship to it.

HIGH  Subgraph URLs directly accessible to external clients. Federation subgraph endpoints are reachable from outside the internal network. External callers can send queries directly to subgraphs, bypassing the gateway's depth limits, rate limits, introspection blocking, and authentication middleware. Subgraphs should only be reachable from the gateway's IP range.

HIGH  Subgraph introspection enabled — schema exposed independently of gateway. Subgraph introspection allows external callers who reach the subgraph directly to enumerate the subgraph's full schema, including internal types and fields not exposed through the gateway. Disable introspection on all subgraphs; only the gateway should expose a schema.

MEDIUM  Subgraph responses not validated before merging — unexpected fields pass through. The gateway merges subgraph responses without field-level validation. A misconfigured or compromised subgraph that returns unexpected fields can inject data into the merged response that downstream clients receive.

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