Some of our favorite clients are startups — working directly with founders who want to build beautiful, user-centric apps on tight timelines and often tighter budgets. Development in this environment can mean that time on features is prioritized over protection which is great for an MVP launch but doesn’t hold up as products begin to scale and attract attention. We experienced this first hand at a previous startup that we worked at, and have carried that lesson with us into our current client work. We‘ve been evolving our process to ensure we secure our backends over the past couple of years, whilst not impacting our quality or speed for clients. Recently we were lucky enough to have that put to the test by one of our clients. The company was acquired by a larger organization who naturally wanted to perform an enterprise level Penetration Test on the app before dishing out their cash.
In this post, I outline a few GraphQL specific security practices that we can now say have been battle-tested. It is by no means an exhaustive list but includes some helpful recipes for securing your API. For a more in-depth reference, you should refer to OWASP
Although most of these points are relevant to any tech stack (or should be translatable), the tech-stack in question and the one we most commonly use is:
NodeJS
GraphQL
Postgres
React/React Native
This post assumes knowledge of directives in GraphQL. However, if you’ve not seen them before it should hopefully still be readable!
Apollo is a good place to learn about them.
Access Control, also known as Authorization, is how your application permits access to resources in your app. Security 101, right, but in anything bigger than a tiny hobby app, it's easy to leave holes in the bucket.
GraphQL makes it simple and elegant to manage access to resources. Suppose the following:
1type Query {2getMyData: Data @authenticate somePublicData: String3}45type Mutation {6updateAnItem(id: ID!, name: String!): Item @authenticate7}
We authenticate each private field with a custom directive and leave the public fields directive-free. The danger is that we stop here, call the system ‘secure’ and release. The likelihood is that, without being security conscious, you’ve gone an implemented the updateAnItem resolver like this:
1const updateAnItem = (root, args) => {2return Item.update({ name: args.name }).where({ id: args.id });3};
Although you have authenticated generic access to updateAnItem, you haven’t checked that this user should be allowed to update this item. Granular authentication must happen at the application logic level, which makes it very hard to keep track of as your application and team grows. The corrected resolver could look like:
1const updateAnItem = (root, args, ctx) => {2return Item.update({ name: args.name }).where({ id: args.id, userId: ctx.userId });3};
Keep an eye out, educate your team to check for them during code-reviews and write automated tests to help guide best practice and prevent regressions.
Most applications are under threat from brute-force attacks on passwords, verification codes or any string that provides some-sort of authorized access to your system. All it takes is a hacker to execute a script that iterates through likely combinations until it strikes gold.
The first step to at least partially cover you would be to add error tracking and alerts to your system. We use Sentry, but there are no doubt others that do a similar job. Log every error that is thrown and set an alert for when it occurs at a frequent rate.
However, if you don’t want to be woken to a trillion alerts from Sentry, then I’d also recommend adding rate-limiting to your vulnerable fields. We’ve built a small directive library for controlling this, it’s as easy as:
1import { createRateLimitDirective } from 'graphql-rate-limit';23const server = new ApolloServer({4typeDefs: gql`5type Mutation {6# Limit to 10 per minute7login(email: String!, password: String!): String! @rateLimit(max: 10, window: 60000)8}9`,10resolvers: {11Mutation: {12login: () => { /* ... */ }13}14},15schemaDirectives: {16rateLimit: createRateLimitDirective({17identifyContext: ctx => ctx.req.ip18})19}20});
More info here: https://github.com/teamplanes/graphql-rate-limit
It can be easy to forget that your UI is not the only interface to your system with the ability to make updates and interact with your business logic, 🤦🏻♂️. A side-effect of this could be that validation checks are baked into the client, and not the server. An example being email verification, perhaps the user should be able to log in whilst their email is not verified yet, but they shouldn’t be able to update their profile or create some data.
Validation checks should be built into your Access-Control, not your UI. So perhaps you can abstract it out with a withEmailVerified directive or you may need to implement in your data layer.
1type Mutation {2login(email String!, password: String!): String3updateProfile: Profile @withEmailVerified4}
A silly mistake, but an easy one to clear up. In development, it’s handy to deliver the full error message and stack trace to the client, but doing this in production could give a hacker enough understanding of the inner workings of your system to exploit it.
Take the following, you have a simple query that accepts an ID (a string) and the resolver of getItemById passes the ID into a Postgres query.
1# Shema2type Query {3getItemById(id: ID!): Item4}56# Query made by client7query myQuery {8getItemById(id: "*") {9id10}11}
You’d end up with an error that could look a little like:
select
from \”items\” where \”id\” = $1 — invalid input syntax for integer: \”
\”
Although this particular message doesn’t give us much other than telling us that there is a table ‘items’ and it has a column named ‘id’. You can easily see how if we had a few other tables referenced, you could build up a picture of the database structure. An attacker could follow a similar process to piece together business logic structure or class implementations.
Most GraphQL server libraries allow you to mask or reformat error messages, with Apollo Server you’d end up with:
1new ApolloServer({2typeDefs,3resolvers,4formatError: error => new Error('Internal server error')5});
Although this is certainly in-exhaustive, hopefully, these points help you in starting on a journey to securing your GraphQL API. Securing your API is never ‘done’, I’d strongly recommend taking a browse through OWASP, a good place to start is with the OWASP Node Goat project’s Top 10.
For further reading on GraphQL specific security concepts, Max Stoiber’s post on Securing Your GraphQL API from Malicious Queries.