Episode 10 tightens the security of incoming data: automatic validation at the schema level, integrating Zod for argument and input validation in resolvers, sanitization techniques to prevent XSS and SQL and NoSQL injection, custom scalars with validation, and designing client-friendly error responses.

The data coming into your API is the biggest attack surface. Episode 10 covers input validation and sanitization — the first line of defense that ensures only correct and safe data reaches your resolvers and database.
We'll take advantage of the automatic validation the schema already provides, add business validation with Zod, apply sanitization techniques to prevent XSS and injection, create custom scalars with built-in validation, and design consistent, client-friendly error response formats.
The good news: GraphQL already gives you structural validation for free. Before resolvers execute, GraphQL checks:
Int won't accept a string).input RegisterInput {
email: String!
age: Int!
role: UserRole!
}
enum UserRole {
ADMIN
MEMBER
}Try sending age: "dua puluh" or role: "SUPER" — GraphQL rejects them before any resolver runs. This is a strong foundation, but it isn't enough: the schema can't validate business logic like "the email must contain a dot" or "the password must be at least 8 characters."
Special format validation can be embedded into custom scalars:
import { GraphQLScalarType, Kind } from "graphql";
const Email = new GraphQLScalarType({
name: "Email",
serialize: (value) => String(value),
parseValue: (value) => {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) {
throw new Error("Format email tidak valid");
}
return value;
},
parseLiteral: (ast) => {
if (ast.kind !== Kind.STRING) throw new Error("Email harus berupa string");
return ast.value;
},
});This Email custom scalar rejects strings that aren't emails at the parsing stage. The graphql-scalars library provides many ready-made scalars like DateTime, Email, and JSON with built-in validation.
The schema's structural validation handles types, but business validation — like string length, patterns, or relationships between fields — needs a library. Zod is a popular modern choice because its type inference works seamlessly with TypeScript:
npm install zodimport { z } from "zod";
const registerInputSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(72),
age: z.number().int().positive().min(13),
});Notice that the Zod schema generates TypeScript types automatically — z.infer<typeof registerInputSchema> gives you safe types without writing them twice. This fuses validation and type safety into one.
There are two layers whose consistency you need to maintain. The first layer is validation in the resolver before the business logic runs:
async function register(_, args, ctx) {
const parsed = registerInputSchema.safeParse(args.input);
if (!parsed.success) {
return {
ok: false,
errors: parsed.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
};
}
return ctx.userService.register(parsed.data);
}Notice the safeParse pattern, which doesn't throw an exception but returns a result you can inspect. This lets you collect all errors at once and display them per field — far more client-friendly than throwing the first error you find.
For rules that involve the database — for example "the username must be unique" — write a dedicated validation function and call it before the operation is saved. Keep these functions separate so they can be reused and tested independently in episode 21.
Sanitization neutralizes malicious input before data is stored or rendered. Two main threats:
const post = await ctx.prisma.post.create({
data: {
title: args.input.title,
body: args.input.body,
},
});Prisma and modern ORMs always use parameterized queries, so user values are never inserted directly into SQL. For MongoDB, avoid the $where operator and dangerous $ operators that could trigger NoSQL injection. For text rendered in the browser, sanitize with a library like DOMPurify on the client side.
Limit input length to prevent abuse: schema String doesn't restrict length. With Zod, add .max(5000) to description fields. Combine this with a request size limit on the HTTP server to protect against giant payloads.
Design the error format from the start so clients can handle it programmatically:
{
"errors": [
{
"field": "email",
"message": "Format email tidak valid"
}
]
}Use the payload pattern with per-field errors as introduced in episode 5, and define clear error codes: VALIDATION_ERROR, NOT_FOUND, UNAUTHORIZED. This categorization will become the foundation of the comprehensive error handling design in episode 11.
Key takeaways:
In the next episode, episode 11, you'll learn about error handling and custom errors — the GraphQL error structure with extensions, custom error classes, the throwing versus returning patterns, error masking for security, and union types for type-safe error handling. Your resolvers will fail gracefully, not by confusing your clients!