How to secure and make GraphQL blazingly fast

An easy and fast way to secure and speed up your GraphQL server

·

5 min read

Security

GraphQL gives frontend developers ability to fetch the exact data they need and having strong schema definition which can be used to generate typesafe clients. However, with great power comes great responsibility. Many articles can be found online on why GraphQL is not secure out of the box and how to build a secure GraphQL API. However, in my opinion, many of these articles are focusing on parts which are against of initial idea of GraphQL, the ability to traverse the graph how you want, without any limitations. Some techniques suggested are depth limiting, query cost analysis and turning off the introspection query. However most of the time these techniques are not perfect and are not applied at all. Additionally, they are also hard to test and the responsibility of implementing those correctly is in the hands of the backend developers. A great in-depth article to read about GraphQL security is https://wundergraph.com/blog/the_complete_graphql_security_guide_fixing_the_13_most_common_graphql_vulnerabilities_to_make_your_api_production_ready.

In my opinion, the most secure way to protect a GraphQL server is to have a whitelist of all operations that are used. However, the question is then, who is responsible for whitelisting the operations? The answer should be the consumers of the GraphQL server aka front-end developers, not the developers of the GraphQL server themself, otherwise, why not just stick to REST or frameworks like tRPC?

So in the end, the frontend developers should have the ability to whitelist all the operations needed for the frontend application. Preferably this should happen on an application basis. And the end user should not have the ability to construct their queries. We can achieve this by using a (reverse) proxy/bff which only let whitelisted operations go through. Luckily with the rise of edge functions such as cloudflare workers or vercel’s edge functions, the frontend devs can create blazingly fast and secure reverse proxies in just seconds.

Thus instead of directly calling your GraphQL endpoint, we call the proxy that is proxying the request:

GraphQL edge function

The reverse proxy/edge function has at least 2 responsibilities. Holding all allowed operations and their mappings and having a secret header which the GraphQL server (origin) accepts. Without the header, the GraphQL server just rejects the request.

Okay, but how to implement this?

Luckily for you, I wrote graphql-ops-proxy, a npm package, which can be used to easily create such edge function/reverse proxy.

A NextJS example using their edge functions

Create an edge function: /pages/api/graphql.ts


import { createEdgeHandler, fromNodeHeaders } from 'graphql-ops-proxy/lib/edge';
import { GeneratedOperation } from 'graphql-ops-proxy/lib/proxy';
import OPERATIONS from '@/__generated__/operations.json';
import { GRAPHQL_ENDPOINT } from '@/config';

const handler = createEdgeHandler(new URL(GRAPHQL_ENDPOINT), OPERATIONS as Array<GeneratedOperation>, {
  proxyHeaders(headers) {
    // todo add x-origin-secret header
    // can add additional headers

    return headers;
  },
  onResponse(resp, headers, op) {
    const responseHeaders = fromNodeHeaders(headers);
    return new Response(resp, {
      status: 200,
      headers: responseHeaders,
    });
  },
});

export const config = {
  runtime: 'edge',
};

export default handler;

I use graphql codegen + https://github.com/ilijaNL/graphql-operation-list to generate typesafe operations which are consumed by the graphql-ops-proxy package to whitelist and map all operations in the frontend project.

Next, we should send all the GraphQL requests to the edge function defined above. This depends on your GraphQL client, however, most of the time it is just sufficient to change the URL to the edge function, /api/graphql.

That is all, we created a fast, serverless and secure GraphQL operations proxy.

Add CDN caching

When using Vercel's edge functions we can use their CDN for caching our API response. Since caching is a tricky topic, you should be careful when to cache and what to cache, that is why it always should be opt-in. Looking at our earlier implementation of the edge function, the createEdgeHandler expects an array of GeneratedOperation. This type has a property behaviour: { ttl: number } which can be used to define the cache TTL on the edge for a specific operation. When using the https://github.com/ilijaNL/graphql-codegen-operation-list we can define the TTL in queries using a directive:

query getCountry ($countryCode: String!) @pcached(ttl: 30) {
  country: countries(filter: { code: { eq: $countryCode } }) {
    code
    name
    capital
  }
}

And modifying our edge function /pages/api/graphql.ts to

import { createEdgeHandler, fromNodeHeaders } from 'graphql-ops-proxy/lib/edge';
import { GeneratedOperation } from 'graphql-ops-proxy/lib/proxy';
import OPERATIONS from '@/__generated__/operations.json';
import { GRAPHQL_ENDPOINT } from '@/config';

const handler = createEdgeHandler(new URL(GRAPHQL_ENDPOINT), OPERATIONS as Array<GeneratedOperation>, {
  proxyHeaders(headers) {
    // todo add x-origin-secret header
    // can add additional headers

    return headers;
  },
  onResponse(resp, headers, op) {
    const responseHeaders = fromNodeHeaders(headers);

      // add cache headers
      if (op.mBehaviour.ttl) {
        responseHeaders.append('Cache-Control', 'public');
        responseHeaders.append('Cache-Control', `s-maxage=${op.mBehaviour.ttl}`);
        responseHeaders.append('Cache-Control', `stale-while-revalidate=${Math.floor(op.mBehaviour.ttl * 2)}`);
      }

    return new Response(resp, {
      status: 200,
      headers: responseHeaders,
    });
  },
});

export const config = {
  runtime: 'edge',
};

export default handler;

will cache the getCountry for 30 seconds on the CDN, additionally, Vercel provides the ability to use a stale-while-revalidate mechanism which automatically will return the staled data and refresh the cache in the background. Additionally, we can extend the cache-control header to have browser caching enabled.

One thing left is to change the body and the method of the request made from the front end. Since Vercel only supports caching GET requests, we should modify the request sent to the proxy. An example using graphql-request:

const graphqlClient = new GraphQLClient('/api/graphql', {
  // using middleware
  requestMiddleware(request) {
    delete request.body;
    const params = new URLSearchParams();
    params.set('op', request.operationName!);
    params.set('v', JSON.stringify(request.variables))

    return {
      ...request,
      method: 'GET',
      url: request.url + '?' + params.toString()
    }
  },
});

Conclusion

We briefly discussed why you should not expose your GraphQL server to the end users and what the alternative could be.
Additionally, we used the CDN to cache the GraphQL requests close to our end users to have a secure and blazingly fast GraphQL operations proxy.

Thanks for reading.

This article was originally written on medium