Topic: mcp server GraphQL mutation security
MCP server GraphQL mutation security — authorization per mutation, input validation, side-effect isolation
GraphQL mutations are the write surface of your MCP server. Unlike queries (which are read-only and whose worst-case outcome is data disclosure), mutations modify state — they create records, update settings, delete data, and trigger downstream effects. Missing per-mutation authorization, mass assignment via underspecified input types, destructive mutations without confirmation gates, and parallel mutation side-effect conflicts are the four mutation-specific security risks in GraphQL MCP servers.
1. Per-mutation authorization — not just schema-level auth
Authentication at the GraphQL schema level confirms the caller has a valid session. It does not confirm they are authorized to execute a specific mutation. Each mutation must enforce its own authorization check against the caller's role, the target resource's ownership, and the operation's risk tier:
// VULNERABLE: only session-level auth, no per-mutation resource authorization
const resolvers = {
Mutation: {
// Root-level middleware checks isAuthenticated — but not resource ownership
updateOrganization: async (_, { orgId, input }, ctx) => {
if (!ctx.userId) throw new AuthenticationError();
// MISSING: is ctx.userId actually an admin of orgId?
await db.organizations.update(orgId, input);
return db.organizations.findById(orgId);
},
deleteUser: async (_, { userId }, ctx) => {
if (!ctx.userId) throw new AuthenticationError();
// MISSING: can ctx.userId delete userId? Must be admin or the user themselves
await db.users.delete(userId);
return { success: true };
},
},
};
// SECURE: per-mutation resource authorization
const resolvers = {
Mutation: {
updateOrganization: async (_, { orgId, input }, ctx) => {
if (!ctx.userId) throw new AuthenticationError();
// Verify caller is an admin of the target organization
const membership = await db.orgMemberships.find({
userId: ctx.userId, orgId
});
if (!membership || membership.role !== "admin") {
throw new ForbiddenError("Only organization admins can update organization settings");
}
// Input allowlist: only allow updating specific fields
const allowedFields = ["name", "description", "logoUrl"];
const sanitizedInput = Object.fromEntries(
Object.entries(input).filter(([k]) => allowedFields.includes(k))
);
await db.organizations.update(orgId, sanitizedInput);
return db.organizations.findById(orgId);
},
deleteUser: async (_, { userId }, ctx) => {
if (!ctx.userId) throw new AuthenticationError();
// Only the user themselves or a system admin can delete a user account
const isSelf = ctx.userId === userId;
const isAdmin = ctx.roles?.includes("system_admin");
if (!isSelf && !isAdmin) {
throw new ForbiddenError("Cannot delete another user's account");
}
// Additional check: cannot delete the last admin of any organization
const adminOrgs = await db.orgMemberships.findAdminOnlyOrgs(userId);
if (adminOrgs.length > 0) {
throw new UserInputError(
`Cannot delete account: sole admin of ${adminOrgs.length} organization(s). ` +
"Transfer admin rights first."
);
}
await db.users.delete(userId);
return { success: true };
},
},
};
2. Mass assignment via loose mutation input types
GraphQL input types that accept broad object shapes can allow callers to set fields they shouldn't be able to modify. An UpdateUserInput that accepts role or isAdmin allows any authenticated caller to escalate their own privileges:
// VULNERABLE: UpdateUserInput accepts role and isAdmin fields
const typeDefs = gql`
input UpdateUserInput {
name: String
bio: String
email: String
role: String # DANGEROUS: caller can set their own role
isAdmin: Boolean # DANGEROUS: caller can grant themselves admin
planTier: String # DANGEROUS: caller can change their plan without paying
}
type Mutation {
updateUser(id: ID!, input: UpdateUserInput!): User
}
`;
// SECURE: separate input types for different authorization levels
const typeDefs = gql`
# Public update — fields any user can change on their own profile
input UpdateProfileInput {
name: String
bio: String
avatarUrl: String
}
# Admin-only update — separate mutation with separate auth check
input AdminUpdateUserInput {
role: String
isAdmin: Boolean
planTier: String
suspended: Boolean
}
type Mutation {
# Self-service: user updates their own profile
updateMyProfile(input: UpdateProfileInput!): User
# Admin-only: admin updates any user's administrative fields
adminUpdateUser(userId: ID!, input: AdminUpdateUserInput!): User
}
`;
const resolvers = {
Mutation: {
updateMyProfile: async (_, { input }, ctx) => {
if (!ctx.userId) throw new AuthenticationError();
// Can only update your own profile — no userId argument
// UpdateProfileInput only contains safe fields
await db.users.update(ctx.userId, input);
return db.users.findById(ctx.userId);
},
adminUpdateUser: async (_, { userId, input }, ctx) => {
if (!ctx.userId) throw new AuthenticationError();
if (!ctx.roles?.includes("system_admin")) {
throw new ForbiddenError("Admin role required");
}
await db.users.update(userId, input);
return db.users.findById(userId);
},
},
};
3. Confirmation gates for destructive mutations
Destructive mutations (delete organization, cancel subscription, wipe dataset) executed by an LLM agent via prompt injection or misunderstood instructions can cause irreversible data loss. A confirmation gate breaks the single-turn attack by requiring a second explicit authorization step:
// VULNERABLE: deleteOrganization executes immediately on a single call
const resolvers = {
Mutation: {
deleteOrganization: async (_, { orgId }, ctx) => {
// A single prompt-injected call executes the deletion
await db.organizations.delete(orgId);
await db.users.deleteByOrgId(orgId);
await storage.deleteOrgBucket(orgId);
return { success: true };
},
},
};
// SECURE: two-step deletion with confirmation token
const resolvers = {
Mutation: {
// Step 1: request deletion — returns a confirmation token, does not delete
requestOrganizationDeletion: async (_, { orgId }, ctx) => {
const membership = await db.orgMemberships.find({ userId: ctx.userId, orgId });
if (!membership || membership.role !== "admin") throw new ForbiddenError();
// Generate a short-lived confirmation token
const confirmToken = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
await db.deletionRequests.create({ orgId, confirmToken, expiresAt, requestedBy: ctx.userId });
return {
confirmToken,
expiresAt: expiresAt.toISOString(),
message: "Organization deletion requested. Use confirmOrganizationDeletion with the token to proceed.",
};
},
// Step 2: confirm with the token returned in step 1
confirmOrganizationDeletion: async (_, { orgId, confirmToken }, ctx) => {
const request = await db.deletionRequests.find({ orgId, confirmToken });
if (!request) throw new UserInputError("Invalid or expired confirmation token");
if (new Date() > request.expiresAt) {
await db.deletionRequests.delete(request.id);
throw new UserInputError("Confirmation token expired. Request deletion again.");
}
if (request.requestedBy !== ctx.userId) throw new ForbiddenError();
// Proceed with deletion
await db.deletionRequests.delete(request.id);
await db.organizations.delete(orgId);
await db.users.deleteByOrgId(orgId);
await storage.deleteOrgBucket(orgId);
return { success: true };
},
},
};
// The two-step pattern breaks prompt injection: a single injected tool call
// can trigger requestOrganizationDeletion but cannot also call confirmOrganizationDeletion
// with the token (which it doesn't have until step 1 completes and returns it)
4. Parallel mutation conflicts — concurrent write safety
GraphQL mutations in a single request are executed sequentially. But across multiple agent tool calls or concurrent sessions, mutations can race. Optimistic concurrency control with a version field prevents lost updates:
// VULNERABLE: no concurrency control — last writer wins, silently overwriting changes
const resolvers = {
Mutation: {
updateDocument: async (_, { docId, content }, ctx) => {
await db.documents.update(docId, { content });
return db.documents.findById(docId);
},
},
};
// SECURE: optimistic concurrency control with version field
const typeDefs = gql`
type Mutation {
# Caller must supply the current version — update fails if version has changed
updateDocument(docId: ID!, content: String!, expectedVersion: Int!): UpdateResult
}
type UpdateResult {
success: Boolean!
document: Document
conflictError: String # Set if expectedVersion was stale
}
`;
const resolvers = {
Mutation: {
updateDocument: async (_, { docId, content, expectedVersion }, ctx) => {
const updated = await db.documents.updateIfVersion(docId, {
content,
version: expectedVersion + 1, // Increment version on update
updatedBy: ctx.userId,
updatedAt: new Date(),
}, expectedVersion); // Only update if current version matches expectedVersion
if (!updated) {
const current = await db.documents.findById(docId);
return {
success: false,
document: current,
conflictError: `Document was modified (now at version ${current.version}). Re-fetch and retry.`,
};
}
return { success: true, document: await db.documents.findById(docId) };
},
},
};
// SQL implementation:
// UPDATE documents SET content = $1, version = $3 + 1, updated_by = $4
// WHERE id = $2 AND version = $3
// RETURNING *
// If rowsAffected = 0, the version didn't match — conflict detected
SkillAudit findings for GraphQL mutation handlers
Run a SkillAudit scan to check mutation authorization patterns, input type field exposure, and confirmation gate presence. See also GraphQL introspection security and the full GraphQL security deep-dive.