Building a secure GraphQL API with Node.js
Lawrence Eagles
March 29, 2022
13 mins readGraphQL provides security straight out of the box with validation and type-checking. However, it doesn’t fully address security concerns around APIs. In this article, we’ll learn how to secure GraphQL APIs by building a simple Node.js application using Fastify and GraphQL.
According to its official documentation, GraphQL is a graph query language for APIs and a runtime for fulfilling those queries with our data. GraphQL gives a clear description of the data in our API and enables us to create fast and flexible APIs while giving clients complete control to describe the data they need. Like REST, GraphQL operates over HTTP, so it is database agnostic and works with any backend language or client.
Fastify is a plugin-based, highly efficient, and exceedingly performant Node.js framework suitable for building fast HTTP servers. Inspired by Hapi and Express, Fastify offers a more developer-friendly and better-performing alternative with low overhead.
Fastify supports GraphQL using the Mercurius plugin. The Mercurius plugin is a configurable GraphQL adapter for Fastify, and we will learn more about it in the subsequent sections.
Let’s start with the prerequisites.
Prerequisites
The following are the prerequisites for this article:
Node.js version 12 or above
Basic knowledge of JavaScript
Basic knowledge of GraphQL
Getting started
To begin, we need to create a basic Node.js server.
Create a starter project by making a project folder, and from this folder, run the code below in the command-line interface (CLI). This will bootstrap our application and install the necessary dependencies.
1// bootstrap npm project
2npm init -y
3
4// install dependencies
5npm i fastify nodemon fastify-plugin mercurius-auth jsonwebtoken
This project uses a specific version of Mercurius. To install it, run the following command:
1npm i mercurius@7.9.1
Next, we enable the ES6 modules to use the standard JavaScript module system instead of commonJS by adding "type": "module"
, to our package.json
file.
Then, we update the NPM scripts with a command to start the Node.js server by opening the package.json
file and editing the scripts section as follows:
1"scripts": {
2 // start the Node.js server in production
3 "start": "node --es-module-specifier-resolution=node ./src/index.js",
4 // use nodemon to restart development server when code is compiled
5 "dev": "nodemon --es-module-specifier-resolution=node ./src/index.js"
6}
Note that the --es-module-specifier-resolution=node
snippet is required to enable interoperability between the ES module and Node’s commonJS modules.
Now, create an src
directory in the root directory. In the src directory, create a graphql
folder containing a schema.js
and a resolvers.js
file. Add the following code to the schema.js
file:
1const schema =`
2 type Query {
3 users: [User]!
4 }
5
6 type User {
7 id: ID!
8 }
9 `;
10 export default schema;
Now, add the following code to the resolvers.js
file:
1const resolvers = {};
2export default resolvers;
We will update the schema.js
and add the resolvers.js
file in the subsequent section. But, we need to create them now, using some boilerplate code, because they are needed for our server to work correctly.
In the src directory, create an index.js
file containing the following code:
1import fastify from 'fastify';
2import mercurius from 'mercurius';
3import jwt from 'jsonwebtoken';
4import mercuriusAuth from 'mercurius-auth';
5import schema from './graphql/schema.js';
6import resolvers from './graphql/resolvers.js';
7
8const port = process.env.PORT || 4500;
9const app = fastify({ logger: true });
10
11// Activate plugins below:
12app.register(
13 mercurius, {
14 schema,
15 resolvers,
16 graphiql: 'playground',
17 queryDepth: 7
18});
19
20// register auth policy
21
22// create server
23const start = async () => {
24 try {
25 await app.listen(port);
26 } catch (err) {
27 app.log.error(err);
28 process.exit(1);
29 }
30};
31start();
The code above creates a primary Fastify server and registers the Mercurius plugin with the options: schema
, resolvers
, graphiql
, and queryDepth
.
Now we can start the server by running npm run dev
. The output is as follows:
1{"level":30,"time":1620202591072,"pid":11775,"hostname":"pc-name","msg":"Server listening at http://127.0.0.1:4500"}
We can now see that our server is working. In the next section, we’ll start building our blog APIs with GraphQL.
Building a secure slog API with Fastify and GraphQL
There are several different strategies to secure an API. Such strategies include:
Authentication and authorization: Authentication is about confirming a user is who they claim to be, while authorization is about permissions. Authentication determines whether a user can log in and, subsequently, remembers that user. Authorization determines what permissions are assigned to an identified user, which regulates whether they can perform operations such as create, read, update, or delete.
Masking errors: Withholding the exact information of a server error to prevent unintentionally providing the client with details that may expose the server’s vulnerabilities.
Query depth limit: Specifying a maximum depth for GraphQL queries. Deeply nested queries are dangerous because they are resource-demanding and expensive to compute. Consequently, they can crash our APIs.
Sanitizing and validating inputs: Use standard web security techniques to prevent users from sending malicious data. We will leverage the built-in GraphQL validation in our application.
This article builds and secures our APIs with the above strategies by using Mercurius and the Mercurius Auth plugin.
For our purposes, the Mercurius Auth plugin has two principal features. First, it enables us to define custom auth directives on fields in our schema. Auth directives are strings that are used as identifiers for protected fields in our schema.
In addition, it allows us to apply custom auth policies to these protected fields when making a GraphQL request.
We start by creating mock data. In the src directory, create a data folder containing an index.js file with the code below:
1export default {
2 users: [
3 { id: 1, username: 'JohnDoe', email: 'John_doe@gmail.com', password: '12345', role: 'admin' },
4 { id: 2, username: 'JaneDoe', email: 'Jane_doe@gmail.com', password: '12345', role: 'user' },
5 { id: 3, username: 'JoeDoe', email: 'Joe_doe@gmail.com', password: '12345', role: 'user' }
6 ]
7};
Next, we set up our schema
by replacing the boilerplate code in the schema.js file with the following code:
1const schema = `
2
3directive @auth(
4 requires: Role = ADMIN,
5 ) on OBJECT | FIELD_DEFINITION
6
7 enum Role {
8 ADMIN
9 USER
10 }
11
12type Query {
13 user(id: ID!): User! @auth(requires: ADMIN)
14 users: [User]! @auth(requires: ADMIN)
15 login(username:String!, password:String!): String
16}
17
18type User {
19 id: ID!
20 username: String!
21 email: String!
22 password: String!
23 role: String!
24}
25`;
26
27export default schema;
We created our GraphQL schema in the code above and defined auth directives for the user and users fields. In a moment, we will apply custom policies to these protected fields.
Now, we add our resolvers by replacing the boilerplate code in the resolvers.js
file with the following code:
1import jwt from 'jsonwebtoken';
2import Data from '../data';
3
4const resolvers = {
5 Query: {
6 users: async (_, obj) => Data.users,
7
8 user: async (_, { id }) => {
9 let user = Data.users.find((user) => user.id == id);
10 if (!user) {
11 throw new Error('unknown user');
12 }
13 return user;
14 },
15
16 login: async (_, { username, password }) => {
17 let user = Data.users.find((user) => user.username === username && user.password === password);
18 if (!user) {
19 throw new Error('unknown user!');
20 }
21
22 const token = jwt.sign({ username: user.username, password: user.password, role: user.role }, 'mysecrete');
23 return token;
24 }
25 }
26};
27
28export default resolvers;
The code above contains resolvers to handle the user, users, and login queries.
Finally, we must add a custom auth policy by registering our Mercurius Auth plugin. To do this in the index.js
file in the src directory, we add the following code below the register auth policy
comment, found on line 19:
1app.register(mercuriusAuth, {
2 authContext(context) {
3 return { identity: context.reply.request.headers['x-user'] };
4 },
5 async applyPolicy(authDirectiveAST, parent, args, context, info) {
6 const token = context.auth.identity;
7 try {
8 const claim = jwt.verify(token, 'mysecrete');
9 } catch (error) {
10 throw new Error(`An error occurred. Try again!`);
11 }
12
13 return true;
14 },
15 authDirective: 'auth'
16});
In our custom policy above, the authContext
method retrieves the user token in the headers, while the applyPolicy
method contains custom policies for authentication and authorization.
Also, when a user fails an authentication or authorization, we throw an error with a generic message such as “An error occurred. Try again!” This message appears in lieu of returning a detailed server error message to the user, which can potentially expose any existing server vulnerabilities.
With this, our work is complete. We will test our APIs in the next section.
Testing the API
First, we start our server by running npm run dev
from the root directory. Next, we retrieve the GraphQL playground from http://localhost:4500/playground.
Now, when we query any protected API, like users
or user
, we get an error as shown below:
So, for our query to be successful, we must authenticate ourselves. Let’s log in to get a token.
To log in, open a new tab in the playground and run the following query:
1query {
2 login(username: "JohnDoe", password: "12345")
3}
If the query is successful, a token is generated and returned as shown in the image below.
Note that the user data used to log in is already in the index.js
file in the data directory.
Attempting to login with user data not in that file (such as username: “John1Doe”) results in an error, as shown below:
Now, by passing our token in the headers as x-user
, we can successfully query our protected APIs, as shown below. Make sure to copy the token received from the login query and use the value for x-user
.
users
query
Here is a sample users
query:
1query {
2 users {
3 id
4 username
5 password
6 email
7 role
8 }
9}
Add your token as the value of the x-user
HTTP header parameter, as shown below:
1{
2 "x-user": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkpvaG5Eb2UiLCJwYXNzd29yZCI6IjEyMzQ1Iiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNjQ0NTA3MDE5fQ.faslGjI6x-ODO2LGYOaTHClGs2MXCBOoMlWPYnwoH18"
3}
This results in the following output:
user
query
Here is a sample user
query:
1query {
2 user(id: "1") {
3 id
4 username
5 email
6 password
7 role
8 }
9}
Add your token as the value of the x-user
HTTP header parameter, as shown below:
1{
2 "x-user": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkpvaG5Eb2UiLCJwYXNzd29yZCI6IjEyMzQ1Iiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNjQ0NTA3MDE5fQ.faslGjI6x-ODO2LGYOaTHClGs2MXCBOoMlWPYnwoH18"
3}
This results in the following output:
Conclusion
In this article, we discovered how easy it is to secure GraphQL APIs by using Fastify and GraphQL to build a simple Node.js application.
As discussed, GraphQL comes with some built-in security, such as validation and type checking. However, the flexibility and power that enable users to request data at will means that security should always be a principal concern.
This article also reviewed some strategies for securing GraphQL APIs. These are authentication and authorization, query depth limit, masking errors, and sanitizing and validating inputs.
These security strategies are tremendously successful, but we can add extra security by implementing other methods, such as query timeouts and rate limits, which specify the frequency with which a client can query an API in each period.
Get started in capture the flag
Learn how to solve capture the flag challenges by watching our virtual 101 workshop on demand.
Try our free online JavaScript code checker tool to see how the Snyk code engine analyses your code for security and quality issues.