MCP Server Security — GraphQL Schema Registry
MCP server GraphQL schema registry security — access control, breaking change detection, and SDL injection via schema push
GraphQL schema registries — Apollo Studio, Hive, or self-hosted schema versioning — are supply-chain infrastructure for MCP servers that expose a GraphQL API. A compromised or misconfigured registry is a supply-chain attack vector: an unauthorized schema push can remove authorization checks from field resolvers, add new fields that bypass rate limits, or inject content into schema descriptions that becomes prompt injection material when the MCP server exposes schema introspection as a tool. This page covers the four attack surfaces specific to schema registry deployments: unauthorized push, SDL injection, missing breaking change approval workflows, and registry endpoint exposure.
Attack 1: Unauthorized schema push — removing authorization directives via registry API
Schema registries accept authenticated HTTP requests to push new schema versions. The authentication is typically a long-lived API key associated with a service account. If that key is leaked (exposed in CI logs, committed to a public repository, reused across services), an attacker can push a modified schema that removes @auth, @requiresRole, or other authorization directives from sensitive fields. The next time the gateway polls the registry for the latest schema, it picks up the malicious version. Field resolvers that relied on directive-enforced authorization now execute without any auth check.
// Minimal schema push verification in CI pipeline
// Run this before allowing the schema to go live in production
import crypto from 'node:crypto';
import { buildSchema, parse } from 'graphql';
const REQUIRED_AUTH_FIELDS = [
'Query.adminUsers',
'Query.billingDetails',
'Mutation.deleteUser',
'Mutation.updateRole',
];
function extractAuthCoverage(sdl) {
const schema = buildSchema(sdl);
const covered = new Set();
for (const typeName of Object.keys(schema.getTypeMap())) {
const type = schema.getType(typeName);
if (!type || !type.getFields) continue;
for (const [fieldName, field] of Object.entries(type.getFields())) {
const hasAuthDirective = (field.astNode?.directives ?? []).some(
d => ['auth', 'requiresRole', 'authenticated', 'hasPermission'].includes(d.name.value)
);
if (hasAuthDirective) {
covered.add(`${typeName}.${fieldName}`);
}
}
}
return covered;
}
export async function validateSchemaBeforePush(proposedSdl, currentSdl) {
const proposedCoverage = extractAuthCoverage(proposedSdl);
const currentCoverage = extractAuthCoverage(currentSdl);
const removedAuthFields = [];
for (const field of currentCoverage) {
if (REQUIRED_AUTH_FIELDS.includes(field) && !proposedCoverage.has(field)) {
removedAuthFields.push(field);
}
}
if (removedAuthFields.length > 0) {
throw new Error(
`Schema push rejected: auth directive removed from required fields: ${removedAuthFields.join(', ')}`
);
}
return true;
}
// In CI, call validateSchemaBeforePush(newSdl, currentSdl) before rover subgraph publish
Attack 2: SDL injection via description fields — prompt injection through schema introspection
GraphQL SDL allows string descriptions on every type, field, argument, and enum value. When an MCP server exposes a get_schema tool (returning introspection results or SDL) that developers use to understand the API, the description fields in the schema become prompt injection channels. An attacker who can push to the schema registry (stolen key, compromised CI) can embed prompt injection payloads in field descriptions. When the MCP agent calls get_schema to understand what queries to run, the LLM reads the poisoned descriptions and follows the injected instructions.
// SDL description sanitizer — strip prompt injection patterns from field descriptions
// Apply before returning introspection results to the LLM
const INJECTION_PATTERNS = [
/ignore\s+(previous|above|prior)\s+instructions?/gi,
/system\s*prompt/gi,
/you\s+are\s+(now|a|an)\s+/gi,
/\[\s*INST\s*\]/gi,
/<\s*\/?\s*(system|user|assistant)\s*>/gi,
/##\s*(instruction|system|override)/gi,
];
function sanitizeSchemaDescriptions(introspectionResult) {
function sanitizeDescription(description) {
if (!description) return description;
for (const pattern of INJECTION_PATTERNS) {
if (pattern.test(description)) {
// Replace with a safe truncated version
return description.replace(pattern, '[REDACTED]');
}
}
return description;
}
function sanitizeType(type) {
if (!type) return type;
return {
...type,
description: sanitizeDescription(type.description),
fields: type.fields?.map(field => ({
...field,
description: sanitizeDescription(field.description),
args: field.args?.map(arg => ({
...arg,
description: sanitizeDescription(arg.description),
})),
})),
inputFields: type.inputFields?.map(f => ({
...f,
description: sanitizeDescription(f.description),
})),
enumValues: type.enumValues?.map(v => ({
...v,
description: sanitizeDescription(v.description),
})),
};
}
return {
...introspectionResult,
__schema: {
...introspectionResult.__schema,
types: introspectionResult.__schema.types.map(sanitizeType),
},
};
}
Attack 3: Missing breaking change approval — resolver auth checks silently removed
Schema registries support "schema checks" that detect breaking changes — fields removed, types changed, required arguments added — before a push goes live. But most teams configure schema checks to block on client-breaking changes and auto-approve additive changes. Removing an authorization directive is additive from the schema perspective (the field still exists, the argument list is unchanged) but is a security regression. Breaking change gates must also check authorization directive coverage, not just structural schema compatibility.
// GitHub Actions CI step — block schema push on auth coverage regression
// .github/workflows/schema-check.yml (partial)
//
// jobs:
// schema-check:
// steps:
// - name: Check schema auth coverage
// run: node scripts/check-schema-auth.mjs
// env:
// PROPOSED_SDL_PATH: schema.graphql
// CURRENT_SDL_URL: ${{ vars.SCHEMA_REGISTRY_URL }}/latest
// scripts/check-schema-auth.mjs
import { readFile } from 'node:fs/promises';
import { validateSchemaBeforePush } from './schema-security.mjs';
const proposedSdl = await readFile(process.env.PROPOSED_SDL_PATH, 'utf8');
// Fetch current production schema from registry
const currentResp = await fetch(process.env.CURRENT_SDL_URL, {
headers: { Authorization: `Bearer ${process.env.SCHEMA_REGISTRY_TOKEN}` }
});
const currentSdl = await currentResp.text();
try {
await validateSchemaBeforePush(proposedSdl, currentSdl);
console.log('Auth coverage check passed');
process.exit(0);
} catch (err) {
console.error('SCHEMA CHECK FAILED:', err.message);
process.exit(1); // Fails CI, blocks merge
}
Attack 4: Registry endpoint exposure — unauthenticated introspection reveals internal types
Schema registry endpoints that serve SDL or introspection results without authentication expose your complete type system — including internal types marked @internal, deprecated fields with their removal rationale, and subgraph URL mappings in Federation configurations. This is not just an information leak; in Apollo Federation the registry exposes the URL of every subgraph service, which are typically internal services not meant to be publicly accessible. A registry that leaks subgraph URLs enables SSRF attacks against internal services from outside the network perimeter.
// Schema registry proxy — authenticate before serving SDL or introspection
import express from 'express';
import { verifyJwt } from './auth.mjs';
const registryProxy = express.Router();
// All schema registry endpoints require authentication
registryProxy.use(async (req, res, next) => {
const token = req.headers.authorization?.replace(/^Bearer\s+/, '');
if (!token) return res.status(401).json({ error: 'Registry access requires authentication' });
try {
const claims = await verifyJwt(token);
// Only internal service accounts can access schema registry
if (!claims.roles?.includes('schema-registry-reader')) {
return res.status(403).json({ error: 'Schema registry access requires schema-registry-reader role' });
}
req.claims = claims;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
});
// Proxy to internal registry after auth
registryProxy.get('/schema', async (req, res) => {
const upstreamResp = await fetch(`${process.env.INTERNAL_REGISTRY_URL}/schema`, {
headers: { 'X-Internal-Auth': process.env.INTERNAL_REGISTRY_SECRET }
});
const sdl = await upstreamResp.text();
// Strip subgraph URLs from Federation schema before returning
const sanitized = sdl.replace(/url:\s*["'][^"']*["']/g, 'url: "[REDACTED]"');
res.type('text/plain').send(sanitized);
});
export default registryProxy;
SkillAudit findings
The following findings appear in SkillAudit audit reports for MCP servers using GraphQL schema registries:
CRITICAL Schema push authentication uses organization-scoped API key — any subgraph can be modified. The schema push credential is a single API key with write access to all subgraphs in the organization. Compromise of this key (CI log leak, repo exposure) allows pushing malicious schema to any service. Use per-subgraph scoped credentials.
CRITICAL No auth directive coverage check before schema push — authorization silently removable. The CI pipeline runs structural breaking change checks but does not verify that @auth/@requiresRole directives are preserved on required fields. A push that removes authorization directives passes all checks and goes live automatically.
HIGH Schema introspection results returned to LLM without description sanitization. The MCP server exposes a get_schema tool that returns raw introspection results. Field descriptions are not sanitized for prompt injection patterns. An attacker who can push schema changes can embed injection instructions that redirect agent behavior when it reads the schema.
HIGH Schema registry endpoint unauthenticated — internal subgraph URLs exposed. The schema registry serves SDL including Federation subgraph URLs without requiring authentication. Internal service URLs are discoverable by unauthenticated clients, enabling SSRF targeting of services that assume they're only accessible from within the network perimeter.
MEDIUM Schema check CI step is advisory-only — can be bypassed via direct push. Breaking change detection is wired into a CI check that fails PRs, but developers with direct registry API access can bypass it by pushing via rover subgraph publish outside CI. Registry push must require a signed CI attestation token, not just authentication.
Paste a GitHub URL at skillaudit.dev to get a graded report card.