Mitigating Resource-Exhaustion Attacks (DDoS) on GraphQL APIs: Best Practices and Strategies
by Musa Knowledge  •    •  10 min read
-on-GraphQL-APIs-Best-Practices-and-Strategies%2Fcover.png&w=3840&q=75)
Over the past few years, GraphQL has become more popular as a way to build APIs, it's important to understand the potential security risks that come with it. One of the most common and devastating attacks or security threats that GraphQL APIs face is resource-exhaustion attacks, which are a type of attacks. This type of attack floods your GraphQl API server with overly complex queries that overwhelm its resources, making it unresponsive and unable the complete the usual request-response lifecycle to genuine users.
In this article, we'll focus on some best practices and strategies to mitigate resource-exhaustion attacks specifically on GraphQl APIs. But before we dive into the solutions, let's first take a closer look at what resource-exhaustion attacks are and how they affect Graphql APIs.
What are Resource-Exhaustion Attacks and How Do They Impact GraphQL API's Performance?
Resource-exhaustion attacks are a type of DDoS attack that aims to exhaust the resources of a server by overwhelming it with a high volume of requests. In the case of GraphQL APIs, these attacks take many forms but they typically involve sending overly complex queries or mutations with circular dependencies that are computationally expensive, consuming a significant amount of the server's processing power. These queries can put a significant amount of strain on your server's resources, causing it to become unresponsive to legitimate users or even crash in some occurrences.
One of the main challenges of mitigating resource-exhaustion attacks on GraphQL APIs is that they don't fit the traditional pattern of flooding a server with a high volume of requests. Instead, the attack is more subtle and involves sending a small number of large, overly complex queries. This makes it harder to detect and mitigate the attack.
Now, let's take a look at some code examples to better understand how this attack can affect a GraphQL API server, In these examples, I'll be making use of but you can equally use GraphIQL Playground (an interactive, in-browser GraphQl IDE) for sending the Graphql queries (just go to this that the server is listening on in your browser). To follow along with the code examples in this article, clone the GitHub repository and follow the instructions in the repo's .
The code block below showcases our GraphQL schema alongside all available queries that client applications can send. A blog Post has an author, and an author which is a User object has many Posts (One-To-Many relationship). If an attacker sends a query to our GraphQl API for a User, which in turn requires 50 or even 100s of his/her Posts, which then requires the author for each Post, which in turn require his/her Posts again, the server will be stuck in an infinite loop, consuming a significant amount of it's processing power while other requests from genuine users are pending.
type User {
id: ID!
role: Role!
name: String
email: String!
posts: [Post!]!
createdAt: Date!
updatedAt: Date!
}
type Post {
id: ID!
title: String!
author: User!
content: String!
createdAt: Date!
updatedAt: Date!
publishedAt: Date!
isPublished: Boolean!
}
type Query {
me: User
user(userId: ID): User
post(userId: ID): Post
}
Now we're going to demonstrate the effects of resource-exhaustion attacks, Open two request tabs on Postman or Apollo's GraphIQl Playground. Moving forward, I'll be using Postman for the examples not just because I have a preference for it or because I think it's faster 👀. In the first tab, we're going to be simulating a genuine user while on the second tab, we'll be simulating a bad actor.
-on-GraphQL-APIs-Best-Practices-and-Strategies%2Fquery-tabs.png&w=2048&q=75)
- In the first tab, we'll be sending the
mequery below to get the current user's information whose context we're running the queries for, together with 10 of their posts. Note that this query has an average response time of just5ms.
query CurrentUser($filter: UserPostsFilterInput) {
me {
id
role
name
email
createdAt
updatedAt
posts(filter: $filter) {
id
title
isPublished
updatedAt
createdAt
publishedAt
}
}
}
GraphQl query variables for the me query above.
{
"filter": {
"postCount": 10
}
}
- In the second tab, we'll be sending the complex query below which has lots of circular dependencies. sending this query, you'll notice it takes a significant amount of time to get a response from the server if at all it doesn't even crash the request tab or the whole application. Now head back to the first tab(genuine user tab) and send off the request again with the second tab pending a response from the server in the background, you'll notice that the
mequery that has an average response time of5 millisecondshas dramatically increased to an average response time of1 minute 10 seconds.
query Complex {
user {
id
role
name
email
createdAt
updatedAt
posts {
id
title
content
isPublished
updatedAt
createdAt
publishedAt
author {
id
role
name
email
createdAt
updatedAt
posts {
id
title
content
isPublished
updatedAt
createdAt
publishedAt
author {
id
role
name
email
createdAt
updatedAt
posts {
id
title
content
isPublished
updatedAt
createdAt
publishedAt
author {
id
role
name
email
createdAt
updatedAt
}
}
}
}
}
}
}
}
-on-GraphQL-APIs-Best-Practices-and-Strategies%2Fme-query-2.jpg&w=2048&q=75)
This query is designed to fetch a lot of data from the server, including lots of the current user posts and the author of those posts. If an attacker were to send a flood of queries like this to your GraphQL server, it could quickly consume all of your server's resources and cause it to become unresponsive.
Of course, this is just a simple example. In a real-world scenario, the attacker might use a more sophisticated method to overwhelm your server. But the basic principle is the same: the attacker is trying to consume all of your server's resources to prevent legitimate users from accessing your service.
Solutions
To mitigate resource-exhaustion attacks on your GraphQL API, you can implement the following best practices and strategies:
- Request Timeouts
- Query Depth Limits
- Cost Analysis on the Query
- Allowing Only Pre-Approved Queries
Implement Query Depth Limits
One way to prevent resource-exhaustion attacks is to implement query depth limits. This means that you set a limit on how deep the queries client applications send can go so that they can't fetch an unlimited amount of data from your server.
To implement query depth limits on your GraphQL API server with NodeJs, install the graphql-depth-limit package from npm. This package allows you to set a maximum depth for your GraphQL queries so that any queries that go deeper than the limit will be rejected.
You can view the complete source code for this example in the branch.
...
import { ApolloServer } from '@apollo/server';
import { default as depthLimit } from "graphql-depth-limit";
const port = 4000;
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true,
validationRules: [
depthLimit(5)
]
});
...
Setting The depth limit of 5 for our queries means that any query that goes deeper than 5 levels will be rejected.
-on-GraphQL-APIs-Best-Practices-and-Strategies%2Fquery-depth-limit-example.jpg&w=2048&q=75)
Use Query Complexity Analysis
Another strategy for mitigating resource-exhaustion attacks is to use query complexity analysis. This means that you analyze each GraphQL query that comes into your server and assign it a complexity score based on factors like the number of fields being requested, the depth of the query, and the number of variables being used. This is somewhat similar to the query-depth-limit strategy.
To implement query complexity analysis on your GraphQL API server with NodeJs, you can use an npm package like graphql-query-complexity. This package allows you to set a maximum complexity for your GraphQL queries so that any queries that exceed the limit will be rejected. Below is an example of how you can use graphql-query-complexity on your GraphQL API server:
You can view the complete source code for this example in the branch.
...
import { ApolloServer } from '@apollo/server';
...
import { createComplexityRule, simpleEstimator } from 'graphql-query-complexity';
const port = 4000;
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true,
validationRules: [
createComplexityRule({
maximumComplexity: 50,
estimators: [
simpleEstimator({
defaultComplexity: 1
})
]
})
]
});
...
In this example we're using the graphql-query-complexity package from npm to set a maximum complexity score of 50 for our queries, in a real-world scenario you would use a higher figure but for this example, 50 is a perfect figure. Any queries that exceed this limit will be rejected.
-on-GraphQL-APIs-Best-Practices-and-Strategies%2Fquery-complexity-example.jpg&w=2048&q=75)
Allowing Only Pre-Approved Queries
This strategy works best if the GraphQl API you're trying to protect is designed for internal use by your client applications and not for public consumption. Going with this strategy, you'll have an approved-list approach that lists all of the allowed queries your server is allowed to execute. This approach re-introduces some dependencies between the client and the server, some automation strategy can be used to mitigate against this issue.
You can view the complete source code for this example in the branch.
Here's an example of how you can allow only pre-approved queries to run in your GraphQL API server:
...
import stringSimilarity from "string-similarity";
import { print } from "graphql/language";
import { ApolloServer } from '@apollo/server';
...
const approvedQueries = [
`query CurrentUser($filter: UserPostsFilterInput) {
me {
...
}
}`
];
const port = 4000;
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true,
plugins: [
{
requestDidStart: async () => ({
didResolveOperation: async ({ request, document }) => {
const query = print(document);
const operation = request.operationName || (document.definitions[0] as any).name?.value || 'unknown';
// It's possible that the queries are not identical due to differences in whitespace,
// comments, or other characters. Hence the use of `string-similarity` package
if (stringSimilarity.findBestMatch(query, approvedQueries).bestMatch.rating < 0.8) {
throw new Error(`Query "${operation}" not approved.`);
}
}
}),
}
]
});
...
In this example, we're using the print function from the graphql package to get a string representation of the AST node. Then we use the string-similarity to score similarity between strings because of possible differences in whitespace, comments etc.
-on-GraphQL-APIs-Best-Practices-and-Strategies%2Fpre-approved-query-example.jpg&w=2048&q=75)
Conclusion
In this article, we've seen how Resource-exhaustion attacks can significantly impact the performance of your GraphQL API and cause your application to become unresponsive or even crash. To mitigate these attacks, you can implement query depth limits, use query complexity analysis, and rate limit your GraphQL endpoint. These best practices and strategies can help keep your GraphQL API running smoothly and protect it from potential security threats.
