9 GraphQL Security Best Practices

GraphQL has no security by default. All doors are open for the most basic attacks. Read more to learn about the exact threats and some simple strategies you can implement to get your users' data under lock and key 🔐

9 GraphQL Security Best Practices

GraphQL has been adopted by the biggest platforms - Facebook, Twitter, Github, Pinterest, Walmart - big businesses that can’t compromise on security. But even though GraphQL can be a very secure option for your API, it does not come secure out of the box. It’s actually the opposite: all doors are open even for the most novice hackers. Plus, GraphQL has its own set of considerations so if you come from REST you might have missed a few important steps!

In 2024 reports on Hackerone still continue to mount, just read how easy it sounds when Watson's Malaysia's API was exploited:

First, a GraphQL API endpoint was discovered and enumerated. Because introspection was enabled, the reporter had visibility into all possible queries and mutations.
Next, the researcher could create his own user account using a mutation called Register. This account gave access to some authenticated functionalities within the GraphQL API.
Last, with the authenticated access the researcher escalated privileges using a mutation called CreateAdminUser. This gave access to even more functionalities within the API.

With access of this privileged account, the reporter:

  • Had access to many features in the GraphQL API
  • Could have modified banners who would have ended up on the front page of the e-commerce website
  • Could modify features of the promotional products (prices, pictures etcetera.)

What’s the risk

The primary categories of attacks that you must absolutely protect your application from are:

  • injections (SQL, XSS, CCS, etc) — using unexpected/random inputs to crash your application or access private data
  • access control — too loose restrictions on queries and mutation allowing anybody to take actions without the necessary role
  • brute-force attacks — submitting a shit load of (leaked) credentials with the hope of guessing correctly
  • DoS (Denial of Service) — flooding your API to make it crash
  • CSRF — induce users to perform unwanted actions by simply clicking a malicious link to your API
Note that these attacks are so basic that they can be performed automatically and at scale. In other words, regardless of the success or domain of your application, you are prone to be the target of a script running on thousands of scraped API endpoints.

Hopefully, there are some very simple strategies to protect your API. Read more to learn how you can implement them 👇

1. Introspection

Most attacks specific to GraphQL start from running an introspection — this is a built-in query that returns your whole data schema. Anyone can know exactly what are the valid queries and mutations of your API and send attacks accordingly until they find a breach.

This feature is enabled by default. If you can (private API) make sure that you disable it.

You can do it with your GraphQL framework (e.g. Apollo)

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production'
});

Or you can use a plugin like graphql-disable-introspection:

app.use("/graphql", bodyParser.json(), graphqlExpress({
	schema: myGraphQLSchema,
	validationRules: [NoIntrospection]
}));

Same thing goes with GraphiQL.

2. Limit Access control with Authorization and Authentication

Another classic API security concern is access control. In most applications, features are accessible based on your authentication status and your role (user, admin).

Not having the right authorization-check layer will expose private data and higher access features to unauthorized users, e.g. deleting an asset without the admin role.

In REST, we can use a simple middleware approach to protect all sub-routes of an API:

app.use('/api/admin', isAdmin())

In GraphQL, we can perform the same thing with the context hook:

const server = new ApolloServer({
    typeDefs,
    resolvers,
    context: async ({ req, res }) => {
        // Check authorization from the headers
        const authHeader = req.headers.authorization || '';
        const token = authHeader.replace('Bearer ', '');
        if (!token) {
            throw new Error('Unauthorized');
        }
        let user = null;
        try {
            user = await verifyJwt(token);
        } catch (e) {
            throw new Error('JWT verification failed');
        }
        // Add user to the context object
        return { user };
    }
});

But throwing an error at this stage would block EVERY unauthenticated request! You might have different queries/mutations with different levels of authentication/authorization.

You guessed it, it’s gonna happen at the resolver level. We can use a cleaner approach with a resolver middleware:

const server = new ApolloServer({
    typeDefs,
    resolvers,
    context: async ({ req, res }) => {
        // Check authorization from the headers
        const authHeader = req.headers.authorization || '';
        const token = authHeader.replace('Bearer ', '');
        if (!token) {
            return { user }
        }
        let user = null;
        try {
            user = await verifyJwt(token);
        } catch (e) {
            throw new Error('JWT verification failed');
        }
        // Add user to the context object
        return { user };
    }
});

// Authentication middleware
const authMiddleware = (next) => (parent, args, context) => {
	if(!context.user) {
		throw new Error("Unauthenticated")
	}
	return next(parent, args, context)
}

const resolvers = {
	Mutation: {
		deleteAsset: authMiddleware(async (parents, {{name, address}}, context) => {
			if (context.user && context.user.role !== "admin") {
				throw new ForbiddenError("Missing role")
			}
			const newAsset = {id: uuid(), name, address}
			context.db.assets.push(newAsset)
			return newAsset
		})
	}
}

Now that we’ve built the first layer of security (access control) the next type of attack are based on death by overload (DoS). Let’s go over different strategies that tackle the threat from different angles but combined together can make your API unshakable 💪

3. Timeout

The easiest strategy to defend your API against large (stress-inducing 😣) queries is to simply configure a maximum time to process a query.

This is actually not specific to GraphQL so it happens in your backend framework (example with Express):

const server = new ApolloServer({ typeDefs, resolvers });
 
const app = express();
server.applyMiddleware({ app });
 
app.listen(5000, () => {
	console.log(`Server running at http://localhost:5000`)
}).setTimeout(1000*60*10)

The advantage is that it doesn’t require prior knowledge about the incoming queries.

But, damage can already be done by the time you reach that limit. This is the final layer of protection if the strategies below fail to block evil queries 👹

4. Query Whitelisting

Another very generic - but efficient - strategy is to whitelist allowed queries. This way you know exactly what you’re going to get. As with timeout, this is a simple strategy that let you constrain the possibilities for hackers.

You can use [persistgraphql](<https://github.com/apollographql/persistgraphql>) by Apollo to auto-generate a list of approved queries at build time.

This might not work for you if you have a complex API, so let’s get fancier!

4. Depth Limiting

One of the specificities of GraphQL is nested queries. They can be awesome, but used excessively they can demand enormous processing resources and become a prison of your own making...

The advantage of this strategy is that a deep query will not even be executed, not putting any load on your server.

The advantage of this strategy - as opposed to timeout - is that a deep query will not even be executed, and thus will not put any load on your server.

You can use the very light graphql-depth-limit library to easily limit the depth of queries. First, check how deep you expect queries to be, and then set a maximum depth accordingly

app.use("/api", graphqlServer({validationRules: [depthLimit(10)]}))

Now, it can’t be perfect right?

Indeed, this strategy does not consider domain-specific queries that might be too expensive without using excessive depth.

So we are still exposed to DoS attacks...

5. Resource Limitations with Complexity Analysis

Resolving even a simple query can, in some cases, be very expensive. Maybe it requires running a big machine learning model or complex algorithms. The timeout above will help limit this effect but as we said above, the damage is already done.

The best way to approach this challenge is to run a complexity analysis (sometimes also called Cost Analysis).

The general idea is to allocate a number to each object returned as well as a maximum complexity limit for queries (see how Github is doing it for their GraphQL API)

There are some packages that might help you in case you need to go down that route. You can start with something simple like graphql-validation-complexity. If you need more control for different use cases you can checkout graphql-cost-analysis.

The limit of this strategy is the difficulty of implementing it. How do you even estimate the complexity of a query? and how do you keep these numbers up to date (scaling/changing infrastructures)?

This is a strategy that usually makes sense once you have implemented all of the above and need a more advanced approach to limiting expensive queries.

We’ve now dealt with large, dirty queries. But what if our attacker comes with a load of small ones - also known as the brute force attack!

6. Rate Limiting to block Brute Force attacks

Aaah Brute Force, the old kid on the block...

How it works: attackers use a list of 700M+ emails and 20M+ passwords (real ones that leaked, reported by Troy Hunt 😱) to hit your login mutation.

Fortunately, this also has an easy way out called rate limiting. If you limit the number of possible login submissions, this attack is (almost) obsolete.

Use the [graphql-limit-plugin](<https://github.com/ravangen/graphql-rate-limit>) to specify this limit on your queries and mutations.

The best way to set it up is to set a large time window between queries/mutations when they are highly vulnerable (like a sign in) and a shorted one for less vulnerable queries/mutations. That way you only limit attackers and not your users.

7. Verbose Errors

Alright, we’ve gone a long way already. But don’t lower your guard, we’re not done yet!

By default, GraphQL is very talkative (like me 🤗). As developers, we love that! It helps us debug errors easily. But when running in production it might be a bit quick to give up sensible information

Uniqueness violation. duplicate key value violates unique constraint user_provider_token

The message might give out information about your database or worse your backend services like Elasticsearch. Again, this is really helpful if you’re the developer trying to figure out what’s going on, but pretty dangerous if it falls into the hand of someone more ill-intended.

The strategy here is not specific to GraphQL. You can use a middleware to wrap your whole server and proxy these too-expressive error messages:

  1. instead, write them in your logs
  2. replace them with more adapted messages
app.use("/api", graphqlServer({validationRules: [depthLimit(10)]}))

// at the very end
app.use((err, req, res, next) => {
  // log the errors for debugging
	console.error(err.stack)
	// return a more user-friendly error
  res.status(500).send('Something broke!')
})

8. Injections

Another boomer attack that is also a threat for GraphQL is the category of injections.

Here’s a simple SQL injection to get user info from a simple (allowed) query. Here’s how someone could bypass your login and get all your users’ details

mutation Login($input: LoginInput!){
	login(input: $input) {
		userId,
		email,
		isAdmin
	}
}
# Query Variables
{
	"input":{
		"email": "name@example.com' OR 1=1-- -",
		"password": "1234"
	}
}

The best way to protect your API from injections is to use input validation for all incoming requests, write custom validators for domain-specific and more complex validations.

9. POST vs GET requests

Fiouuu! Alright last one!

GraphQL can be sent over GET and POST HTTP requests.

If you think it’s the same thing, that’s bad!!!

Here is a valid mutation over a GET request:

http://badgraphqlapi.com/graphql?mutation={...}&variables={...}&operation=...

Imagine I’m a bad guy, I can build a link that changes the email of an account. I can then put my email as the new value and put that on the web. Even though the mutation requires the user to be authenticated because this is a GET request, an authenticated user might just click without knowing the consequences... and BAM I have control over their account!

In some GraphQL frameworks like Apollo mutation can’t be done over GET requests but, as they say, trust but verify - make sure mutations can’t be performed over GET request with automated tests (learn how to test a GraphQL API).

10. Do you follow these best practices in your GraphQL endpoint?

Since GraphQL is new and lacks just the proper tooling, many development teams are just skipping security... In practice, how can you ensure that your development team keeps implementing those best practices over time and your codebase evolution? Plus, are those 9 simple best practices really enough?

That's why at Escape, we created the first GraphQL scanner that allows developers to find and fix bugs in GraphQL applications during the development lifecycle (in CI/CD) before they even reach production!

Want to get tips on how to secure GraphQL and see Escape in action for your GraphQL APIs? Book a demo with us 👇

Escape understands the business logic of GraphQL APIs and looks for more than 50 kinds of vulnerabilities so that you never have to worry again about:

  • Resolver performance (N+1 issues, cyclic queries, query complexity DOS…)
  • Tenant isolation (access control, data segregation between users…)
  • Sensitive data leaks (personally identifiable information, tokens, stack traces, secrets…)
  • Injections (SQL, NoSQL, XSS…) and requests forgery
  • Compliance: PCI-DSS, ISO-27001, HIPAA, etc.
  • … and more than 50+ advanced security issues!

And more than that. You can't secure what you can't see right? Escape helps you to build a comprehensive inventory of all your APIs, including GraphQL, REST, and gRPC.

We constantly update our engine with state-of-the-art GraphQL Security research so that you never have to worry about the security of your GraphQL application again!

Conclusion

Because of the paradigm shift introduced with GraphQL, it’s easy to forget the basic security practices when moving over to GraphQL. From similar patterns (authorization/authentication) to new considerations (disabling introspection, rate limiting, etc). Here is your checklist:

☑️ Disable introspection (and GraphiQL)
☑️ Authorization and authentication to limit access to your API
☑️ Set a timeout
☑️ Create a whitelist of queries
☑️ Set a maximum depth limit for queries
☑️ Setup a cost analysis to limit complex/expensive queries
☑️ Set a maximum rate limit for requests
☑️ Stop default verbose errors
☑️ Add input validations


Want to learn more about GraphQL security? Check out the following articles:

💡
Do you prefer hands-on learning about GraphQL Security? Start your lessons with our API Security Academy focused on GraphQL and learn how to build safe GraphQL APIs.