GraphQL Error Handling Best Practices for Security

Error messages in GraphQL are rarely seen as a security concern, yet they should. The risk? Leaking information and data! Learn more about GraphQL errors and how to handle them.

GraphQL Error Handling Best Practices for Security

Error messages in GraphQL are seldom seen as a security concern, yet they should.

Default error messages are way too verbose. They can disclose critical information about the internal structure of your backend, or worse, private data like internal messages and private keys.

It lets malicious users easily run a fuzzy search on your API to find vulnerabilities. For example, if the error message contains information about the database models and how the user input is used to query data, an attacker could run requests until they figure out the right format and then exploit a vulnerability such as a SQL injection.

We found so many vulnerabilities in GraphQL apps coming from overly verbose error messages that we integrated a sensitive stack trace detection system in our GraphQL Security Testing Platform to alert developers that their APIs are vulnerable as early as in the CI/CD.

In this post, we’ll explain how GraphQL errors work, how to mask verbose errors but also how to create your custom errors leveraging the extendable GraphQL spec.

How errors work in GraphQL

GraphQL errors are fundamentally different from REST errors. You no longer rely on status codes and status texts.

According to the latest spec, the response of a GraphQL endpoint should always contain either the `data` field or the errors field, and in some cases, both:

Example of error message

As you can see above, an error object has the following fields:

  • message: the error message - if you throw an error in the resolver, its message will appear here;
  • locations: contains the line and column (starting from 1) of the syntax element that is concerned with the error (in the example above, this is the "a" of "author". The column is 5 for the indent but would be 1 if we remove the indent in the query);
  • path: the path of the response field which experienced the error (firstPost → author).

Additionally, you can add the extensions field which is a map containing any custom field you see fit:

https://spec.graphql.org/October2021/#example-8b658
Note: it is recommended to use the extensions field for custom fields even though no error will be thrown when doing otherwise.

The code field of the extensions is usually implemented by the framework. For example, here are the default codes in Apollo:

  • GRAPHQL_PARSE_FAILED (SyntaxError)
  • GRAPHQL_VALIDATION_FAILED (ValidationError)
  • BAD_USER_INPUT (UserInputError)
  • UNAUTHENTICATED (AuthenticationError)
  • FORBIDDEN (ForbiddenError)
  • PERSISTED_QUERY_NOT_FOUND (PersistedQueryNotFoundError)
  • PERSISTED_QUERY_NOT_SUPPORTED (PersistedQueryNotSupportedError)
  • INTERNAL_SERVER_ERROR (None)

We’ll see below how you can define your own custom error at the end.

How to mask sensitive information and stack traces

Back to the initial problem: not giving up sensitive information to consumers of your API.

The errors in GraphQL are very comprehensive: they tell you what broke, where it broke both in the query and in your code. That is great for developer experience, but clearly not intended for the end-user.

Information like the stacktrace - the path tracing all the way back to the breaking point in your code - are usually found in the logs, not in the error message.

The solution to catch these default errors before they get sent to the end-user is to use a custom formatError function in your GraphQL server (framework agnostic), to:

  1. log the error internally for debugging,
  2. update the error that you don’t want to expose to clients in production,
  3. send a *user-friendly* error.
formatError: (err) => {
	// 1. Log the error for internal debugging
    logger(err, req, Date.now())
    
    // 2. Update the error message
    if (err.message.includes('Database Error')) {
        err.message = 'Internal server error'
    }
		
	// 3. Return the error message
    return {
    	...err
        stack: process.env.NODE_ENV === 'production' ? undefined : err.stack
	}
}

resolvers.ts

Cool, now our API is not going to betray us.

But we can do more. Let’s see how we can leverage the GraphQL spec we saw above to create our own custom errors.

How to write custom errors

There are three key parts we can play within our GraphQL errors:

  1. the message: giving more context to the user, e.g., “password should have at least 6 characters”;
  2. the extensions code: this is a custom field but a good practice in API error design as it allows you to easily classify the different types of errors (Input error, Authorization error, Database error, etc)
  3. any other extensions field: this is where you get creative! you can add extra context, e.g., the field name that caused an error if the input is coming from a form.

Let’s go through an example.

The first step is to extend the GraphQLError class to create a custom error:

⚠️ As mentioned above, you should put custom fields under extensions to comply with the latest spec

import {GraphQLError, GraphQLErrorExtensions} from 'graphql'

class InputValidationError extends GraphQLError {
	extensions: GraphQLErrorExtensions;
    constructor(message: string, field: string) {
        super(message);
        this.extensions = {
            statusCode: 500,
            code: "INPUT_VALIDATION_FAILED",
            field,
        };
    }
}

Custom error

Now you can throw the error where necessary:

post: (_parent:null, {id}: QueryPostArgs, context: Context) => {
	if(id >= context.db.posts.length) {
    	throw new InputValidationError("Index out of range", "id")
    }
	// Fetch and return the corresponding post
}

resolvers.ts

Finally, make sure to return the extensions field if you use a custom formatError function:

formatError: (err) => {
	return {
    	message: err.message,
		extensions: err.extensions, // this is already returned by default
		stack: process.env.NODE_ENV === 'production' ? undefined : err.stack
	}
}

server.ts

Alternatively, most GraphQL frameworks have a GraphQLError wrapper that lets you throw custom errors on the fly without writing new ones.

import {ApolloError} from 'apollo-server-errors

throw new ApolloError('Index out of range', 'INPUT_VALIDATION_FAILED', {field: 'id'})

Custom error in Apollo with ApolloError

Never leak info in your error messages again!

Now you understand the importance of not disclosing information in your message, you might want that it never happens again in your GraphQL applications!

In addition, error messages are just one of many GraphQL vulnerabilities. Since GraphQL is new and lacks just lacked the proper tooling, many development teams are just skipping security...

That's why at Escape, we created the first GraphQL Security scanner that allows developers to find and fix vulnerabilities in GraphQL applications during the development lifecycle before they even reach production!

x

It 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
  • … and more than 50+ advanced security issues!

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!

Start monitoring the security of your endpoints today with a 7-day free trial.

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