Episode 15 secures a GraphQL API from common threats: query depth and complexity attacks, introspection abuse, request-based and cost-based rate limiting, correct CORS, HTTPS and secure headers, and Automatic Persisted Queries with a whitelist.

GraphQL brings incredible power — and that power can be abused. An innocent-looking query can burden the server hundreds of times more than a normal query. Episode 15 secures your GraphQL API against the most common threats.
We'll cover query depth limiting, query complexity analysis, rate limiting, introspection, CORS configuration, HTTPS and secure headers, and persisted queries with a whitelist — all part of the security checklist that must be applied before production.
The most characteristic GraphQL threat is deep nesting. A query like user { posts { comments { user { posts { comments ... } } } } } can make resolvers run millions of times. The server burns resources on abnormal queries, opening the door to Denial of Service (DoS).
Other threats: batch attacks that send many operations in a single request, introspection abuse to map the schema, and queries requesting unbounded lists. All three need different strategies — starting with query restrictions.
Depth limiting restricts how deeply a query can nest; install the library via npm install graphql-depth-limit:
npm install graphql-depth-limitimport depthLimit from "graphql-depth-limit";
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(10)],
});Queries that go past depth 10 are rejected at the validation stage — before any resolver runs. The chosen value needs testing: too small and it rejects legitimate queries (like a deep dashboard), too large and it loses protection. Start at 8-12 for most applications.
A depth limit doesn't count how many resolvers actually execute. Query complexity analysis goes further: each field gets a cost, and the total query cost is capped.
npm install graphql-cost-analysisimport costAnalysis from "graphql-cost-analysis";
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
costAnalysis({
maximumCost: 1000,
defaultCost: 1,
costMap: {
User: { posts: { multiply: 5 } },
},
}),
],
});Expensive fields (like large lists or external calls) get a higher cost via costMap. The total query is compared against maximumCost; anything over the limit is rejected. This approach defeats queries that are cheap "per field" but expensive "cumulatively".
Rate limiting prevents one client from overwhelming the server within a time window. For GraphQL there are two approaches:
import { createRateLimitDirective } from "graphql-rate-limit";
const rateLimitDirective = createRateLimitDirective({
keyGenerator: () => "global",
max: 100,
window: "1m",
message: "Terlalu banyak request, coba lagi nanti",
});Apply rate limiting per user using the identity from ctx.user.id, and consider a whitelist for service-to-service API keys. Combine request-based and cost-based for layered protection.
Introspection lets clients and tooling read the entire schema — useful in development, but it gives attackers a map in production:
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== "production",
validationRules: [
...(process.env.NODE_ENV === "production"
? [depthLimit(10), costAnalysis(costOptions)]
: []),
],
});Remember: this is "security by obscurity" and not a replacement for authorization (episode 14) — sensitive fields must still be protected directly. Some teams actually leave introspection on for public APIs and rely on other protections.
Configure CORS with a specific origin whitelist; don't use * for APIs with credentials:
import cors from "cors";
app.use(cors({
origin: ["https://app.kalian.com", "https://admin.kalian.com"],
credentials: true,
}));Always serve the API over HTTPS with modern TLS, and add secure headers like Strict-Transport-Security. Behind a CDN or load balancer, make sure the proxy forwards the needed headers.
Persisted queries separate the query from the request: the client sends a query hash, and the server maps the hash to an approved query. Two benefits: less bandwidth and a security whitelist.
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: { ttl: 300 },
});For a strict whitelist, register approved queries explicitly and reject unknown ones — this also disables the arbitrary queries attackers often use. APQ will be revisited from a performance and caching angle in episode 19.
Key takeaways:
graphql-depth-limit rejects overly deep queries; cost analysis caps total query cost.In the next episode, episode 16, you'll learn about subscriptions for real-time features — the subscription concept, WebSocket transport with graphql-ws, Apollo 4 server setup, the PubSub pattern, Redis-based PubSub for production, subscription security, and client integration with Apollo Client. Your API will start "speaking" in real time!