Does Next.js need a WAF?
A WAF can protect your Next.js app from passive scanning as well as active exploitation of known vulnerabilities. If you need to be PCI DSS v4.0 compliant then a WAF is required, but what about other types of application?
How to protect GraphQL backends using Arcjet. Implementing rate limiting and bot protection for Yoga + Next.js.
Yoga is a GraphQL server that focuses on performance and developer experience and can be nicely integrated into Next.js. In this tutorial we will demonstrate how to integrate the Arcjet rate limiting and bot detection security functionality to protect your GraphQL backend.
Let's start with installing the key packages.
Next.js is a web application development framework. To install Next.js:
mkdir arcjet
cd arcjet
npx create-next-app@latest
. You may receive a prompt stating: Need to install the following packages: create-next-app@x.x.x Ok to proceed? (y)
- enter y
to proceed.cd ratelimit
npm install
GraphQL Yoga provides a lightweight, fully-featured JavaScript and TypeScript GraphQL server.
To install GraphQL Yoga and the necessary GraphQL tools: npm i graphql-yoga graphql
npm i @arcjet/next
.env.local
file in the project’s root. Add the following to this file:ARCJET_KEY=keyhere
To maintain the reliability and stability of your API, it’s crucial for servers to enforce appropriate API usage limits. Enforcement prevents server overload by managing traffic surges and ensures fair resource distribution among users.
Limits also mitigate abuse and malicious attacks, control costs associated with resource usage and improve performance by preventing any single user from monopolizing server resources.
To impose limits on the calls made to the API endpoint, we will be using the following two Arcjet primitives:
Let's create some example API endpoints:
Create a /src/app/api/graphql
directory and a route.ts
file within it:
import { createSchema, createYoga } from 'graphql-yoga';
import arcjet, { detectBot, tokenBucket } from '@arcjet/next';
import { NextResponse } from 'next/server';
// Initialize Arcjet with your configuration, tracking by IP by default.
const aj = arcjet({
key: process.env.ARCJET_KEY!,
// No characteristics specified means tracking by IP.
rules: [
// Create a token bucket rate limit. Other algorithms are supported.
tokenBucket({
mode: "LIVE", // will block requests. Use "DRY_RUN" to log only
refillRate: 5, // refill 5 tokens per interval
interval: 10, // refill every 10 seconds
capacity: 10, // bucket maximum capacity of 10 tokens
}),
detectBot({
mode: "LIVE", // will block requests. Use "DRY_RUN" to log only
// Block all bots except search engine crawlers. See the full list of bots
// for other options: https://arcjet.com/bot-list
allow: ["CATEGORY:SEARCH_ENGINE"],
}),
],
});
// Create GraphQL Yoga instance.
const { handleRequest } = createYoga({
schema: createSchema({
typeDefs: /* GraphQL */ `
type Query {
greetings: String
}
`,
resolvers: {
Query: {
greetings: () =>
"This is the `greetings` field of the root `Query` type",
},
},
}),
// Configure GraphQL Yoga to use the correct endpoint.
graphqlEndpoint: "/api/graphql",
// Yoga needs to know how to create a valid Next response.
fetchAPI: { Response },
});
export async function GET(req: Request) {
const decision = await aj.protect(req, { requested: 5 }); // Deduct 5 tokens from the bucket
console.log("Arcjet decision", decision);
if (decision.isDenied()) {
return NextResponse.json(
{ error: "Too Many Requests", reason: decision.reason },
{ status: 429 }
);
}
// Define a context object if needed or use an empty object.
const ctx = {};
// Forward the request to the GraphQL handler with both request and context.
return handleRequest(req, ctx);
}
export async function POST(req: Request) {
const decision = await aj.protect(req, { requested: 5 }); // Deduct 5 tokens from the bucket
console.log("Arcjet decision", decision);
if (decision.isDenied()) {
return NextResponse.json(
{ error: "Too Many Requests", reason: decision.reason },
{ status: 429 }
);
}
// Define a context object if needed or use an empty object.
const ctx = {};
// Forward the request to the GraphQL handler with both request and context
return handleRequest(req, ctx);
}
export async function OPTIONS(req: Request) {
// Handle OPTIONS requests here if needed, usually for CORS.
return NextResponse.json({ message: "OPTIONS request received" });
}
Now, there is Arcjet rate limiting in place! To test your web application run npm run dev
and visit: http://localhost:3000/api/graphql
Send queries in quick succession or repeatedly refresh the page. Per the rule, you were only allowed to make 2 requests because we set the bucket size to 10 and the "cost" per request to 5.
To test bot protection, issue a curl
request:
curl http://localhost/api/graphql
By default, curl
is considered an automated client so is blocked.
As this is an API, you probably want to allow requests from tools and programmatic clients. Arcjet supports allowing specific clients as well as categories of clients.
In this case we'll just allow curl
to access our API. Change the detectBot
rule to the following:
detectBot({
mode: "LIVE",
// configured with a list of bots to allow from
// https://arcjet.com/bot-list - all other detected bots will be blocked
allow: [
"CURL", // allows the default user-agent of the `curl` tool
],
})
Execute the curl
request again and you'll see your request is allowed.
As this is an API, we also want to allow requests from other programming languages and tools, so we'll use the CATEGORY:PROGRAMMATIC
and CATEGORY:TOOL
categories:
detectBot({
mode: "LIVE",
// configured with a list of bots to allow from
// https://arcjet.com/bot-list - all other detected bots will be blocked
allow: [
"CATEGORY:PROGRAMMATIC",
"CATEGORY:TOOL", // curl is included in this category
],
})
We don't need to include curl
because it is already included in the "CATEGORY:TOOL"
category.
In addition to the protection measures provided by Arcjet, you can change the following settings to improve security:
By default, the GraphiQL playground interface is only available in development mode and is served at the /graphql
route for GET
requests with an Accept: text/html
header.
To disable it completely, add the graphiql: false
line to the Yoga instance:
const { handleRequest } = createYoga({
graphiql: false,
schema: createSchema({
typeDefs: /* GraphQL */ `
type Query {
greetings: String
}
`,
resolvers: {
Query: {
greetings: () =>
"This is the `greetings` field of the root `Query` type",
},
},
}),
However, disabling the playground should not be considered as a security measure. As long as introspection and/or the suggestion feature are enabled, malicious attackers have the means of enumerating the schema.
Schema introspection is used to ask a GraphQL schema for information about what queries/features it supports.
To disable introspection, first install the plugin: npm i @graphql-yoga/plugin-disable-introspection
Next, import the plugin:
import { useDisableIntrospection } from '@graphql-yoga/plugin-disable-introspection'
Then, add it to the Yoga instance:
const { handleRequest } = createYoga({
graphiql: false,
plugins: [useDisableIntrospection()],
schema: createSchema({
typeDefs: /* GraphQL */ `
type Query {
greetings: String
}
`,
resolvers: {
Query: {
greetings: () =>
"This is the `greetings` field of the root `Query` type",
},
},
}),
To enable introspection for certain users, view the Yoga documentation for more details.
When constructing GraphQL operations, autocomplete suggestions will be made. To disable these suggestions, the graphql-armor plugin suite can be implemented.
To disable introspection, first install the plugin: npm i @escape.tech/graphql-armor-block-field-suggestions
Next import the plugin:
import { blockFieldSuggestionsPlugin } from '@escape.tech/graphql-armor-block-field-suggestions'
Next, add it to the plugin array:
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",
},
},
}),
To prevent unauthorized actors from reverse-engineering your GraphQL schema and executing arbitrary operations, it is highly recommended to use persisted operations.
Instead of sending full queries with each request, clients use unique identifiers to reference pre-approved and predefined operations stored on the server. This prevents malicious attackers from being able to see or modify the queries - ensuring only safe and trusted queries are executed.
By default the plugin provided by Yoga for persisted operations uses the APQ Specification.
Import the plugin:
import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations'
Add it to the Yoga instance along with a stored SHA-256 hash of the query string:
const store = {
ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38:
"{__typename}",
};
// Create GraphQL Yoga instance.
const { handleRequest } = createYoga({
graphiql: false,
plugins: [
useDisableIntrospection(),
blockFieldSuggestionsPlugin(),
usePersistedOperations({
getPersistedOperation(sha256Hash: string) {
return store[sha256Hash];
},
extractPersistedOperationId(_params, request) {
const url = new URL(request.url);
return url.searchParams.get("id");
},
}),
],
Now, the hash can be used to reference the operation by providing it as the value to the id query parameter:
Try adding a hash for the {greetings}
query string:
const store = {
ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38:
"{__typename}",
bbe53eef47146afab42c34603960ca40be3aa8bfa67a49f3f5f3d6d006df38f0:
"{greetings}",
};
Now use the hash value representing the {greetings}
operation:
Any operations that are not registered to a hash will not be allowed.
You now have a grasp of integrating Arcjet protection with other security mechanisms to safeguard your GraphQL endpoint.
Additionally, you can enhance protection against API abuse by implementing further measures, such as setting up Cross-Origin Resource Sharing (CORS) rules and applying Cross-Site Request Forgery (CSRF) protections
A WAF can protect your Next.js app from passive scanning as well as active exploitation of known vulnerabilities. If you need to be PCI DSS v4.0 compliant then a WAF is required, but what about other types of application?
Nosecone is an open source library to set security headers like Content Security Policy (CSP) and HTTP Strict Transport Security (HSTS) on Next.js, SvelteKit, and other JavaScript frameworks using Bun, Deno, or Node.js. Security headers as code.
Server actions are an elegant way to handle simple functions for common actions like form submissions, but they're a public API so you still need to consider security.
Get the full posts by email every week.