Validate and Sanitize GraphQL Input Types Server-Side
Implement centralized input validation in GraphQL using custom validation functions, Zod schemas, and input type transforms
GraphQL validates that inputs match their declared types, but it does not enforce business constraints — string length, number ranges, email format, or XSS sanitization. Without a validation layer, resolvers receive raw user input and must each implement their own checks. Centralizing validation with Zod schemas keeps resolvers clean and ensures consistent error messages.
When to Use This
- Any mutation that accepts user input (create, update, delete)
- APIs exposed to public clients where input cannot be trusted
- Schemas with complex input types that need cross-field validation
Prerequisites
- A GraphQL server (Apollo Server, GraphQL Yoga)
- Zod installed (
npm install zod)
Solution
1. Define Input Types in the Schema
// schema.ts
import gql from 'graphql-tag';
export const typeDefs = gql`
input CreatePostInput {
title: String!
content: String!
tags: [String!]!
publishedAt: String
}
input UpdateUserInput {
name: String
bio: String
website: String
}
type ValidationError {
field: String!
message: String!
}
type ValidationErrors {
errors: [ValidationError!]!
}
type Post {
id: ID!
title: String!
content: String!
tags: [String!]!
publishedAt: String
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updateUser(id: ID!, input: UpdateUserInput!): User!
}
`;
2. Create Zod Validation Schemas
// validation/schemas.ts
import { z } from 'zod';
export const createPostSchema = z.object({
title: z.string()
.min(3, 'Title must be at least 3 characters')
.max(200, 'Title must be at most 200 characters')
.trim(),
content: z.string()
.min(10, 'Content must be at least 10 characters')
.max(50000, 'Content is too long')
.trim(),
tags: z.array(z.string().min(1).max(30).trim())
.min(1, 'At least one tag is required')
.max(10, 'Maximum 10 tags allowed'),
publishedAt: z.string()
.datetime()
.optional()
.refine(
(date) => !date || new Date(date) > new Date(),
'Published date must be in the future'
),
});
export const updateUserSchema = z.object({
name: z.string().min(1).max(100).trim().optional(),
bio: z.string().max(500).trim().optional(),
website: z.string().url('Must be a valid URL').optional().or(z.literal('')),
});
export function validateInput<T>(
schema: z.ZodSchema<T>,
input: unknown
): { success: true; data: T } | { success: false; errors: { field: string; message: string }[] } {
const result = schema.safeParse(input);
if (result.success) {
return { success: true, data: result.data };
}
const errors = result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
}));
return { success: false, errors };
}
3. Use Validation in Resolvers
// resolvers.ts
import { createPostSchema, updateUserSchema, validateInput } from './validation/schemas';
export const resolvers = {
Mutation: {
createPost: async (
_: unknown,
{ input }: { input: any },
ctx: { db: { posts: { create: (data: any) => Promise<any> }; user: { id: string } } }
) => {
const result = validateInput(createPostSchema, input);
if (!result.success) {
throw new ValidationException(result.errors);
}
return ctx.db.posts.create({
...result.data,
authorId: ctx.user.id,
});
},
updateUser: async (
_: unknown,
{ id, input }: { id: string; input: any },
ctx: { db: { users: { update: (id: string, data: any) => Promise<any> } } }
) => {
const result = validateInput(updateUserSchema, input);
if (!result.success) {
throw new ValidationException(result.errors);
}
return ctx.db.users.update(id, result.data);
},
},
};
4. Custom Error Class for Validation
// errors.ts
export class ValidationException extends Error {
constructor(
public readonly validationErrors: { field: string; message: string }[]
) {
super('Validation failed');
this.name = 'ValidationException';
}
}
5. Format Errors in Apollo Server
// server.ts
import { ApolloServer } from '@apollo/server';
import { ValidationException } from './errors';
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formatted, error) => {
if (error instanceof ValidationException) {
return {
message: 'Input validation failed',
extensions: {
code: 'VALIDATION_ERROR',
errors: error.validationErrors,
},
};
}
return formatted;
},
});
How It Works
- Zod schemas define constraints declaratively — min/max length, regex patterns, URL format, custom refinements
safeParsereturns a discriminated union:{ success: true, data }or{ success: false, error }— no exceptions thrownvalidateInputconverts Zod errors to a flat{ field, message }array that clients can map to form fieldsValidationExceptioncarries structured errors through GraphQL’s error handling into theextensionsblock
Variants
Cross-Field Validation
Validate that two fields relate to each other:
export const eventSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
}).refine(
(data) => new Date(data.endDate) > new Date(data.startDate),
{ message: 'End date must be after start date', path: ['endDate'] }
);
Sanitization with DOMPurify
Strip HTML from user input to prevent XSS:
import DOMPurify from 'isomorphic-dompurify';
export const htmlContentSchema = z.string()
.transform((value) => DOMPurify.sanitize(value))
.refine((value) => value.length > 0, 'Content cannot be empty after sanitization');
Validation Directives
Use schema directives to declaratively mark fields for validation:
input CreatePostInput {
title: String @constraint(minLength: 3, maxLength: 200)
content: String @constraint(minLength: 10, maxLength: 50000)
}
Best Practices
-
For a deeper guide, see Complete Guide to GraphQL Security.
-
Validate at the boundary — run validation as the first step in every resolver that accepts input
-
Sanitize, don’t just validate — use
.trim()and HTML sanitizers to clean input, not just reject it -
Return structured errors — field-level errors let clients map messages to specific form inputs
-
Reuse schemas across layers — share Zod schemas between GraphQL resolvers, REST handlers, and frontend forms
Common Mistakes
- Trusting GraphQL’s built-in type checking — GraphQL validates types but not constraints like min length or format
- Validating in each resolver separately — leads to inconsistent rules and duplicated logic
- Throwing raw errors — clients can’t programmatically handle validation errors without structured extensions
- Forgetting to sanitize HTML — validation alone doesn’t prevent XSS; strip dangerous tags before storing
Troubleshooting
- Query returns null unexpectedly: verify resolvers, data loaders, and authorization.
- N+1 query performance issue: use DataLoader or equivalent batching. Inspect resolver execution traces.
- Introspection disabled in production breaks tools: enable it only in development, or use schema artifacts in CI.
- Mutation input rejected: confirm input validation, custom scalars, and whether variables are passed as the right type.
- Subscription stops receiving events: check the pub/sub backend, event filtering, and that the resolver is emitting events.
Production Notes
- Deploy gradually using canary or blue-green to catch regressions early.
- Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
- Document the rollback in the runbook; test the procedure in staging at least once per quarter.
- Review structured logs with correlation IDs to trace requests end-to-end during incidents.
Key Takeaways
- Apply validate and sanitize graphql input types server-side when you need a practical solution for your use case.
- Monitor performance after implementation; measure latency, errors, and resource usage before and after.
- Check the Troubleshooting section for common failures; most have documented root causes with fixes.
- Keep dependencies updated and run tests in CI to prevent production regressions.
Common Production Pitfalls
- Copying the example without adapting it to real data volumes and failure modes.
- Skipping load and error-injection tests before the first production deployment.
- Hard-coding values that should be configurable per environment.
- Forgetting to add logging and monitoring at each step.
- Deploying without a rollback plan or a tested backup strategy.
- Assuming the minimal example will scale without adding caching or batching.
- Not documenting the version and configuration used in production.
- Letting the recipe sit unchanged when dependencies or scale evolve.
Frequently Asked Questions
Should I use Zod or GraphQL custom scalars for validation?
Use scalars for format validation (email, date). Use Zod for business constraints (min length, cross-field rules). They complement each other.
Can I share Zod schemas with the frontend?
Yes. If your frontend uses TypeScript, import the same Zod schemas for client-side validation before sending to the API.
How do I handle file uploads with validation?
Validate file metadata (size, MIME type) in the resolver. GraphQL's multipart upload spec doesn't support Zod directly.
What about performance with large inputs?
Zod is fast for typical input sizes. For very large payloads (100KB+), consider streaming validation or pre-size limits at the HTTP layer.
Related Resources
Custom GraphQL Scalar Types for Dates, Emails, and JSON
Define custom GraphQL scalars for Date, Email, URL, and JSON fields with serialization, parsing, and validation logic
RecipeStructured GraphQL Errors with Extension Codes
Implement structured error handling in GraphQL with custom error classes, extension codes, and consistent error formatting for clients
RecipeSchema-Based Data Validation with Zod in TypeScript
Validate and sanitize incoming data using Zod schemas with TypeScript inference, custom refinements, and error formatting for reliable API and form validation
RecipeField-Level Auth with Custom GraphQL Directives
Add field-level authorization to GraphQL with custom schema directives. Check roles, permissions, and ownership per field in Apollo Server.
PatternGraphQL Mutation Validation Pattern: Centralized Input Validation
Centralize GraphQL mutation validation with reusable rules, custom scalars, and structured errors. Includes TypeScript examples and best practices.