What makes GraphQL a different security problem?
GraphQL lets a client ask for exactly the fields it needs in one request to one endpoint. The schema names types and fields, queries read data, mutations write it, and resolvers fetch each field. That flexibility is the product feature and the attack surface: one HTTP request can contain many operations, aliases, and nested selections that a request-count limit never sees.
A REST rate limit often maps one request to one resource. A GraphQL POST can batch users, expand friends ten levels deep, or alias the same field a hundred times. You still need HTTP-level bot detection and rate limits, but you also need schema-aware controls: timeouts, depth and alias caps, query cost, authentication, and per-object authorization in the resolver.
For more information about the API baseline that still applies, see API security best practices. For more information about HTTP-level algorithms, see rate limiting algorithms.
How do GraphiQL and introspection help attackers?
GraphiQL (often at /graphql, /playground, or /console) autocompletes fields and returns detailed validation errors. A malformed query can tell an attacker which arguments conflict, which fields exist, and how to fix the syntax. Treat the playground as a development tool, not a production feature.
Introspection is enabled by default on most GraphQL servers. A client can send __schema and receive every type, field, argument, and mutation. That is a machine-readable map of the attack surface. Disable introspection in production, or restrict it to authenticated operators. Disabling GraphiQL alone is not enough if introspection or field suggestions still run.
How do batch, alias, duplication, and circular queries cause denial of service?
These techniques all aim to spend more resolver work than the HTTP request count suggests.
Query batching. HTTP batching sends an array of separate operations in one request body. If you do not cap the batch size, an attacker enumerates IDs, brute-forces a login mutation, or fans out reads while a request-count limiter sees one hit.
[ { "query": "query { userInfo(id: \"1\") { name email } }" }, { "query": "query { userInfo(id: \"2\") { name email } }" }, { "query": "query { userInfo(id: \"3\") { name email } }" }]Apollo Server 4 and 5 reject this by default: allowBatchedHttpRequests is false, and turning it on is what creates the exposure. You cannot express this as repeated fields inside a single operation, because two userInfo fields with different arguments and no alias fail the spec's overlapping-fields validation before any resolver runs. That is what aliases are for, which is the next technique.
Aliases. Even when batching is off, aliases let the client fetch the same field many times with different arguments.
query { user1: userInfo(id: "1") { name email } user2: userInfo(id: "2") { name email } user3: userInfo(id: "3") { name email }}Duplication. Repeating the same fields in one selection set forces the server to parse and, depending on the implementation, resolve them again.
Circular or deep queries. Nested list fields such as friends { friends { friends { ... } } } walk the graph until the process runs out of time or memory. Without a max depth or a cost cap, a single well-formed query is enough.
How do injection attacks reach GraphQL resolvers?
Queries and mutations are untrusted input. If a resolver concatenates an argument into SQL, HTML, a shell command, or an outbound URL, the usual injection classes apply.
- XSS. A mutation that stores
bioand a profile page that renders it as HTML can persist a script. Sanitize or encode at the output boundary; a GraphQL type ofStringis not a sanitizer. - SQL injection.
userInfo(id: "admin' OR '1'='1'")is only dangerous if the resolver interpolatesidinto a query string. Use parameterized queries. GraphQL does not protect the database. - SSRF. A mutation that fetches
urlon behalf of the client can reach link-local or VPC hosts. Allow only required schemes and hosts, and check resolved addresses. - Command injection. Passing a user string into
wget,convert, or a shell is the same bug it is in REST.
Validate arguments with an explicit schema (length, pattern, enum, ID format) before the resolver touches storage or the network.
Which GraphQL attack vectors should you mitigate first?
The following table maps each vector to the risk and the control that actually stops it.
| Vector | Risk | Mitigation |
|---|---|---|
| GraphiQL and field suggestions | Schema and syntax hints for unauthenticated clients | Disable the playground in production; block field suggestions |
| Introspection | Full schema disclosure | Set |
| Query batching | Many operations per HTTP request; rate-limit bypass | Cap operations per request; prefer persisted operations |
| Aliases and field duplication | CPU and resolver amplification | Max aliases, max tokens, and query cost |
| Circular or deep selections | Memory and time exhaustion | Max depth, execution timeout, and cost that grows with depth |
| Injection in arguments | XSS, SQLi, SSRF, command injection | Parameterized queries, output encoding, URL allowlists, argument schemas |
| Missing object authorization | Cross-tenant reads and writes through a global ID | Authorize in the resolver on principal, tenant, and object |
| Automated clients and floods | Scraping, credential stuffing, cost exhaustion | HTTP |
Layer the controls. An HTTP rate limit without query cost still admits one expensive document. Query cost without authentication still serves the graph to strangers.
How do you add Arcjet and GraphQL Armor to Apollo Server 5?
Apollo Server 5.x is current (@apollo/server 5.5.x at the time of writing). GraphQL Armor 3.2 supports Apollo Server 4 and 5. Pin @arcjet/node@1.10.0, not the old 1.0.0-alpha.28 line.
Keep graphql on the 16 line here. graphql 17 is released, but Apollo Server 5 declares a ^16.11.0 peer dependency, so installing 17 alongside it gives you a peer warning and an unsupported combination. graphql-yoga does accept 15, 16, or 17 if that is the server you run.
GraphQL Armor also has peer dependencies that npm will not install for you: @envelop/core and an exact @escape.tech/graphql-armor-types@0.7.0, which is where the GraphQLArmorConfig type below comes from.
npm install @apollo/server@^5.5.0 graphql@^16.14.0 @arcjet/node@1.10.0 \ @escape.tech/graphql-armor@^3.2.0 @envelop/core@^5.0.0 \ @escape.tech/graphql-armor-types@0.7.0Create a client with a token bucket and a bot allow list. interval is seconds. Pass { requested } on every protect() call so each HTTP request spends a known number of tokens.
import { ApolloServer } from "@apollo/server";import { startStandaloneServer } from "@apollo/server/standalone";import { ApolloServerPluginLandingPageDisabled } from "@apollo/server/plugin/disabled";import { ApolloArmor } from "@escape.tech/graphql-armor";import type { GraphQLArmorConfig } from "@escape.tech/graphql-armor-types";import arcjet, { detectBot, tokenBucket } from "@arcjet/node";import { GraphQLError } from "graphql";import type { IncomingMessage } from "node:http";
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [ tokenBucket({ mode: "LIVE", refillRate: 5, interval: 10, capacity: 10, }), detectBot({ mode: "LIVE", allow: ["CATEGORY:SEARCH_ENGINE"], }), ],});
const armorConfig: GraphQLArmorConfig = { maxAliases: { n: 3 }, maxDepth: { n: 5 }, maxTokens: { n: 1000 }, blockFieldSuggestion: { enabled: true }, costLimit: { maxCost: 100, objectCost: 2, scalarCost: 1, depthCostFactor: 1.5, },};
const armor = new ApolloArmor(armorConfig);const protection = armor.protect();startStandaloneServer is fine for a local demo. In production, mount Apollo on Express or Fastify so you control HTTP timeouts, TLS, and middleware order. Disable the landing page and introspection when the server is reachable from the public internet.
How do you authenticate and authorize GraphQL operations?
A valid token is not permission to read every User or call every mutation. Authenticate in context, then authorize in the resolver with the principal, tenant, requested object, and operation.
type Principal = { id: string; role: "admin" | "member"; tenantId: string;};
type Context = { user: Principal | null };
async function userFromAuthorization( header: string | undefined,): Promise<Principal | null> { if (!header?.startsWith("Bearer ")) { return null; } // Verify signature, issuer, audience, and expiry. Then load tenant and role. return verifyAccessToken(header.slice("Bearer ".length));}
// Replace with your JWT or session verifier.declare function verifyAccessToken(token: string): Promise<Principal | null>;
type User = Principal & { name: string; email: string; bio?: string };const users: User[] = [];
const typeDefs = `#graphql type User { id: ID! name: String! email: String! bio: String friends: [User!]! }
type Query { userInfo(id: ID!): User }
type Mutation { changeUserInfo(id: ID!, name: String!, bio: String): User }`;
const resolvers = { Query: { userInfo(_parent: unknown, { id }: { id: string }, ctx: Context) { const principal = ctx.user; if (!principal) { throw new GraphQLError("Unauthenticated", { extensions: { code: "UNAUTHENTICATED" }, }); } const user = users.find((u) => u.id === id); if (!user || user.tenantId !== principal.tenantId) { throw new GraphQLError("Not found", { extensions: { code: "FORBIDDEN" }, }); } return user; }, }, Mutation: { changeUserInfo( _parent: unknown, args: { id: string; name: string; bio?: string }, ctx: Context, ) { const principal = ctx.user; if (!principal) { throw new GraphQLError("Unauthenticated", { extensions: { code: "UNAUTHENTICATED" }, }); } const isSelf = principal.id === args.id; if (principal.role !== "admin" && !isSelf) { throw new GraphQLError("Not allowed", { extensions: { code: "FORBIDDEN" }, }); } if ( !/^[a-zA-Z0-9. ]+$/.test(args.name) || (args.bio?.length ?? 0) > 100 ) { throw new GraphQLError("Invalid input", { extensions: { code: "BAD_USER_INPUT" }, }); } const user = users.find( (u) => u.id === args.id && u.tenantId === principal.tenantId, ); if (!user) { throw new GraphQLError("Not found", { extensions: { code: "FORBIDDEN" }, }); } user.name = args.name; if (args.bio !== undefined) { user.bio = args.bio; } return user; }, },};
const server = new ApolloServer<Context>({ typeDefs, resolvers, introspection: false, plugins: [...protection.plugins, ApolloServerPluginLandingPageDisabled()], validationRules: protection.validationRules,});
const { url } = await startStandaloneServer(server, { listen: { port: 4000 }, async context({ req }: { req: IncomingMessage }): Promise<Context> { const decision = await aj.protect(req, { requested: 1 }); if (decision.isDenied()) { throw new GraphQLError("Forbidden", { extensions: { http: { status: decision.reason.isRateLimit() ? 429 : 403, }, }, }); } return { user: await userFromAuthorization(req.headers.authorization) }; },});
console.log(`Server ready at ${url}`);Look up the record by ID and tenant. An opaque ID is not proof of access. Apply the same check to child fields (friends, exports, files). A support role that may read userInfo should not automatically call changeUserInfo. For more information about object-level authorization, see API security best practices.
How do you calculate and cap query cost?
HTTP rate limits count requests. Query cost counts work. Assign a cost to scalars, objects, and list fields so that friends is more expensive than name, and so that each extra level of nesting multiplies the total. Reject the document during validation when the estimate exceeds maxCost.
GraphQL Armor's costLimit does that estimate for you. Tune the knobs against real queries from your clients:
maxCostis the ceiling for one operation.objectCostandscalarCostare the base weights.depthCostFactorraises the price of each nested level, which is how you stop circularfriendswalks without guessing a single magic depth.
You can also attach explicit weights in the resolver layer when a field is known to be expensive (full-text search, export, AI). Charge more tokens on the Arcjet requested argument for those operations, and keep the Armor cap as a backstop for anonymous document shape.
const decision = await aj.protect(req, { requested: operationName === "ExportUsers" ? 20 : 1,});A timeout around execution is the last stop: cost estimates assume typical resolver time. A resolver that hits a slow dependency can still overrun. Set an execution deadline and cancel downstream work when it fires.
Persisted operations shrink the problem further: the server only runs hashes it already approved, so an attacker cannot invent a new circular query. For more information about Yoga, token buckets, bot allow lists, and the SHA-256 persisted-operation flow, see GraphQL rate limiting and bot detection.
Frequently asked questions
Why is GraphQL harder to rate-limit than REST?
One HTTP request can contain many operations, aliases, and nested selections. A request-count limiter sees one hit while resolvers do the work of many REST calls. You need query cost, depth and alias caps, and HTTP limits together.
Should you leave introspection enabled in production?
No. Introspection returns the full schema and is a map of the attack surface. Disable it for public clients, or gate it on an operator role. Disabling GraphiQL alone is not enough if introspection or field suggestions still run.
How do you stop circular friend queries?
Set a max depth, a query-cost ceiling that grows with depth, and an execution timeout. Persisted operations prevent clients from inventing new deep documents.
Does GraphQL protect you from SQL injection?
No. Arguments are untrusted input. If a resolver interpolates an ID into SQL, HTML, a shell, or a URL, the usual injection classes apply. Use parameterized queries, output encoding, and argument schemas.
How do you authorize a GraphQL field?
Authenticate in context, then authorize in the resolver with the principal, tenant, requested object, and operation. Look up records by ID and tenant. A valid token is not permission to read every User or call every mutation.
Which Apollo Server version should you use?
Apollo Server 5.x is current. Pin @apollo/server 5.5.x, GraphQL Armor 3.2 (supports Apollo 4 and 5), and @arcjet/node@1.10.0. Stay on the graphql 16 line: Apollo Server 5 declares a ^16.11.0 peer dependency, so graphql 17 is an unsupported combination even though it is released.
Application security in your code
Protect your application with Arcjet
Get rate limits, bot detection, and attack blocking in your request handlers.