API security

How do you add rate limits and bot detection to a GraphQL API?

On Yoga and Next.js, call Arcjet protect() on the GraphQL route with a token bucket (interval in seconds, plus requested tokens) and a detectBot allow list. Disable GraphiQL, introspection, and field suggestions, then serve only SHA-256 persisted operations.

7 min read
In short: On Yoga and Next.js, call Arcjet protect() on the GraphQL route with a token bucket (interval in seconds, plus requested tokens) and a detectBot allow list. Disable GraphiQL, introspection, and field suggestions, then serve only SHA-256 persisted operations.

How do you add rate limits and bot detection to a GraphQL API?

Put the HTTP controls on the route that serves GraphQL, then add schema-aware limits so one document cannot do the work of a thousand REST calls. A request-count limiter sees one POST. The document inside that POST can batch users, alias the same field, or walk friends until the process tips over. You need both layers: a token bucket and bot allow list on the HTTP request, and persisted operations so the server only runs queries it already hashed.

This guide uses GraphQL Yoga on the Next.js App Router with @arcjet/next. Yoga is a good fit because createYoga exposes handleRequest that speaks the Fetch API. Arcjet's Next.js adapter accepts that same Request. Call protect() in GET and POST before you forward the request to Yoga. Do not put the limiter only in a Next.js middleware matcher and assume GraphQL is covered; run it on the route that parses the document.

For more information about GraphQL attack classes and resolver authorization, see how to secure a GraphQL API. For more information about token bucket versus fixed or sliding windows, see rate limiting algorithms.

What do you install?

Create a Next.js app if you do not already have one, then pin the adapters:

Terminal window
npm install graphql-yoga@^5.22.0 graphql@^16.14.0 @arcjet/next@1.10.0

Add ARCJET_KEY to .env.local from the Arcjet dashboard. Keep the key out of the client bundle; the GraphQL route runs on the server.

How do you rate-limit GraphQL with a token bucket?

Create src/app/api/graphql/route.ts. interval accepts either a number of seconds or a duration string, so interval: 10 and interval: "10s" are the same rule. Pick one form and keep it consistent across a codebase. refillRate is how many tokens return each interval. capacity is the burst ceiling. Pass { requested } on every protect() call; that is how token bucket expresses variable cost. Sliding and fixed windows count events and have no requested field.

import arcjet, { detectBot, tokenBucket } from "@arcjet/next";
import { createSchema, createYoga } from "graphql-yoga";
import { NextResponse } from "next/server";
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 { handleRequest } = createYoga({
schema: createSchema({
typeDefs: /* GraphQL */ `
type Query {
greetings: String
}
`,
resolvers: {
Query: {
greetings: () =>
"This is the `greetings` field of the root `Query` type",
},
},
}),
graphqlEndpoint: "/api/graphql",
fetchAPI: { Response },
});
async function protectAndHandle(req: Request) {
const decision = await aj.protect(req, { requested: 5 });
if (decision.isDenied()) {
const status = decision.reason.isRateLimit() ? 429 : 403;
return NextResponse.json(
{ error: status === 429 ? "Too Many Requests" : "Forbidden" },
{ status },
);
}
return handleRequest(req, {});
}
export function GET(req: Request) {
return protectAndHandle(req);
}
export function POST(req: Request) {
return protectAndHandle(req);
}

requested: 5 against capacity: 10 means two full-cost requests exhaust the bucket. A cheap introspection-free read can pass { requested: 1 }; an export or nested search can pass a larger integer. After the bucket empties, further requests return 429 until refillRate tokens arrive at the next interval.

The default characteristic is the client IP. After you authenticate, key the bucket on user, API key, or tenant and pass that value into protect().

Which bots should you allow on a GraphQL API?

detectBot takes exactly one of allow or deny. On a browser product you often allow search engines and deny everything else. On an API you usually want tools and programmatic clients. The following table lists common allow-list entries.

Allow entryWhat it matchesWhen to use it
CURL

The default user agent of the curl CLI

Local debugging and scripted checks that you want to permit by name

CATEGORY:PROGRAMMATICHTTP clients in common languages and SDKsPublic APIs whose legitimate callers are other programs
CATEGORY:TOOL

Developer tools, including curl

APIs that partners and internal scripts hit from CLIs
CATEGORY:SEARCH_ENGINEVerified search-engine crawlersPublic pages or schemas you want indexed, not private APIs

CURL is already inside CATEGORY:TOOL, so you do not need both. An empty allow: [] blocks every detected bot, including curl. That is why a first curl http://localhost:3000/api/graphql returns 403 until you add a tool or programmatic category.

For more information about allow versus deny lists, verified bots, and spoofed user agents, see bot detection techniques.

How do you disable GraphiQL, introspection, and field suggestions?

Rate limits and bot rules do not hide the schema. GraphiQL, introspection, and autocomplete still teach an attacker the graph.

Yoga serves GraphiQL in development on GET requests that accept text/html. Turn it off with graphiql: false. Then install the introspection and suggestion plugins:

Terminal window
npm install @graphql-yoga/plugin-disable-introspection @escape.tech/graphql-armor-block-field-suggestions
import { useDisableIntrospection } from "@graphql-yoga/plugin-disable-introspection";
import { blockFieldSuggestionsPlugin } from "@escape.tech/graphql-armor-block-field-suggestions";
const { handleRequest } = createYoga({
graphiql: false,
plugins: [useDisableIntrospection(), blockFieldSuggestionsPlugin()],
schema: createSchema({
typeDefs: /* GraphQL */ `
type Query {
greetings: String
}
`,
resolvers: {
Query: {
greetings: () =>
"This is the `greetings` field of the root `Query` type",
},
},
}),
graphqlEndpoint: "/api/graphql",
fetchAPI: { Response },
});

Disabling the playground is not a security control by itself. As long as introspection or field suggestions remain, a client can still enumerate the schema.

How do persisted operations work with SHA-256 hashes?

Persisted operations replace the query text on the wire with a hash the server already knows. The client cannot send an arbitrary document, so circular queries, unexpected mutations, and schema probing fail closed unless you registered that exact string.

The flow is:

  1. Canonicalize each operation your client ships (the exact query string, including whitespace you choose to treat as significant).
  2. Compute the SHA-256 digest of that string and encode it as hexadecimal.
  3. Store hash → document on the server. Generate this map at build time from the client's operation files so the store cannot drift from the app.
  4. The client sends only the hash. Yoga's plugin supports Automatic Persisted Queries (APQ) by default, or you can read a custom id query parameter.
  5. getPersistedOperation(sha256Hash) returns the stored document or null. null means reject.
import { createHash } from "node:crypto";
import { usePersistedOperations } from "@graphql-yoga/plugin-persisted-operations";
function sha256(document: string) {
return createHash("sha256").update(document).digest("hex");
}
const greetings = "{greetings}";
const typename = "{__typename}";
const store: Record<string, string> = {
[sha256(typename)]: typename,
[sha256(greetings)]: greetings,
};
const { handleRequest } = createYoga({
graphiql: false,
plugins: [
useDisableIntrospection(),
blockFieldSuggestionsPlugin(),
usePersistedOperations({
getPersistedOperation(sha256Hash: string) {
return store[sha256Hash] ?? null;
},
extractPersistedOperationId(_params, request) {
return new URL(request.url).searchParams.get("id");
},
}),
],
schema: createSchema({
typeDefs: /* GraphQL */ `
type Query {
greetings: String
}
`,
resolvers: {
Query: {
greetings: () =>
"This is the `greetings` field of the root `Query` type",
},
},
}),
graphqlEndpoint: "/api/graphql",
fetchAPI: { Response },
});

A client then calls /api/graphql?id=<hex> instead of posting a query string. Any hash that is not in store is rejected. Rotate the store when you add or change operations; treat it like a deployment artifact, not a runtime guess. If two clients hash different whitespace or argument names, they produce different digests, so generate the map from the same source files the client bundles.

APQ clients send extensions.persistedQuery.sha256Hash instead of id. Use that extractor when your existing mobile or web client already speaks APQ. Either way, the server must look up the hash and refuse unknown documents. Do not fall back to executing a raw query body when the hash is missing; that fallback reopens arbitrary documents and defeats the control.

What else should you add?

HTTP token buckets and bot allow lists stop floods and unwanted automation. They do not authorize a tenant to read another tenant's userInfo, and they do not cap nested friends selections. Combine this route with query cost, max depth, and resolver authorization from how to secure a GraphQL API. Add CORS and CSRF rules if the same origin also serves a browser session.

Frequently asked questions

How do you rate-limit GraphQL with Arcjet?

Call protect() on the Yoga route before handleRequest. Use tokenBucket with refillRate, interval (seconds as a number, or a duration string such as "10s"), and capacity. Pass { requested } so a cheap read costs 1 token and an expensive operation costs more.

Why does curl get blocked on a GraphQL API?

curl is a detected tool. An empty allow: [] blocks every detected bot, including curl. Add CURL, CATEGORY:TOOL, or CATEGORY:PROGRAMMATIC when legitimate callers are scripts and HTTP clients.

Which detectBot allow-list entries are useful on an API?

CURL for the default curl user agent, CATEGORY:PROGRAMMATIC for language HTTP clients, CATEGORY:TOOL for developer tools (includes curl), and CATEGORY:SEARCH_ENGINE for verified crawlers on public content.

How do persisted operations work?

Hash each approved query with SHA-256, store hash -> document on the server, and have the client send only the hash. getPersistedOperation returns the document or null. Refuse unknown hashes and do not fall back to a raw query body.

Is disabling GraphiQL enough to hide the schema?

No. Introspection and field suggestions still enumerate types and fields. Disable all three in production, or restrict them to authenticated operators.

Do HTTP rate limits replace query cost?

No. A token bucket limits requests. Query cost, max depth, and resolver authorization limit work inside one document. Use both.

Application security in your code

Protect your application with Arcjet

Get rate limits, bot detection, and attack blocking in your request handlers.