Cookbook

Different limits for Mutations/Queries

Check out By Query or Mutation to rate limiting specific queries or mutations.

Excluding SSR or Static Rendering from Vercel

When statically rendering your pages, you would potentially hit rate limits. To exclude your SSR or static rendering from being rate limited from Vercel, you can check for existence and value of a known header that your build system is sending. This can be anything unguessable (UUID, your cat walking over the keyboard, the hash of your favorite movie).

For example in Vercel, you can set an Environment Variable that will be available in your code. Within your build script or your sever-side rendering, you would do something like "if that env variable is set, add it as a header to the request".

const noRateLimitsMiddleware = new ApolloLink((operation, forward) => {
  operation.setContext(({ headers = {} }) => ({
    headers: {
      ...headers,
      'my-custom-header': process.env.STELLATE_RATE_LIMITING_SHARED_SECRET || null,
    }
  }));

  return forward(operation);
})

Your rate limiting configuration would know about the same shared secret value and omit applying rules when the value is matched.

import { Config } from 'stellate'

const SHARED_SSR_SECRET = 'my-cat-just-walked-over-the-keyboard'

const config: Config = {
  config: {
    rateLimits: (req) => {
      if (req.headers['my-custom-header'] && req.headers['my-custom-header'] === SHARED_SSR_SECRET) {
        return []
      }

      return [
        {
          name: 'IP limit without SSR',
          // Requests will be grouped by ip
          groupBy: 'ip',
          // dry run state will not block any requests, but only show it in the UI
          state: 'dryRun',
          // Limit consumers that do more than 50 requests in the last 60 seconds
          limit: {
            type: 'RequestCount',
            window: '1m',
            budget: 50,
          },
        },
      ]
    },
  },
}
export default config

Proxy in front of Stellate

If you use another CDN like Fastly or Cloudflare in front of Stellate and want to rate limit by IP, you can't use the req.ip object, but will have to use headers coming from Fastly or Cloudflare. You can either use the x-forwarded-for or x-forwarded-host headers, which contian a comma separated list of IPs like so:

import { Config } from 'stellate'

const config: Config = {
  config: {
    rateLimits: (req) => {
      const forwardedForHeader = Array.isArray(req.headers['x-forwarded-for'])
        ? req.headers['x-forwarded-for'][0]
        : req.headers['x-forwarded-for']

      const ip = forwardedForHeader
        ? forwardedForHeader.split(',')[0]
        : req.ip

      return [
        {
          name: 'IP limit with proxy',
          // Requests will be grouped by ip
          group: ip,
          // dry run state will not block any requests, but only show it in the UI
          state: 'dryRun',
          // Limit consumers that do more than 50 requests in the last 60 seconds
          limit: {
            type: 'RequestCount',
            window: '1m',
            budget: 50,
          },
        },
      ]
    },
  },
}
export default config

Custom consumer identifiers

Sometimes limiting by IP is not enough. You might have an auth cookie, API key or JSON Web Token (JWT) by which you want to rate limit your consumers. Stellate got you covered. Corresponding to the complexity of your use-case, Stellate allows you to use different kinds of ways to identify your consumers.

With the groupBy property, you can add custom values that identify individual consumers by any property present in the request:

import { Config } from 'stellate'

const config: Config = {
  config: {
    rateLimits: [
      {
        name: 'My limit',
        // group by Authorization header
        groupBy: { header: 'authorization' },
        // or
        // group by cookie
        groupBy: { cookie: 'my-cookie' },
        // or
        // group by a jwt claim
        groupBy: { jwt: 'my-claim' },
        // Start with dry run to not block any requests
        state: 'dryRun',
        // Allow 5 requests per minute
        limit: {
          type: 'RequestCount',
          window: '1m',
          budget: 5,
        },
      },
    ],
  },
}
export default config

Sometimes using a static value is not enough. Let's say you want to go by Authentication token by default, but by IP as a fallback. In that case you can calculate the consumer identifier with a custom function:

import { Config } from 'stellate'

const config: Config = {
  config: {
    getConsumerIdentifiers: (req) => ({
      'my-identifier': req.headers.authorization ?? req.ip,
    }),
    rateLimits: [
      {
        name: 'my-identifier based limit',
        // group by Authorization header
        groupBy: { consumerIdentifier: 'my-identifier' },
        // Start with dry run to not block any requests
        state: 'dryRun',
        // Allow 5 requests per minute
        limit: {
          type: 'RequestCount',
          window: '1m',
          budget: 5,
        },
      },
    ],
  },
}
export default config

You can read more here about how to debug functions.

You can read more about groupBy in the Rate Limiting API Reference.

Different plans

If you have a service that has different plans for free and paid users, you can make use of JWT and multiple rate limits to return different limits per consumer.

import { Config } from 'stellate'

const paidLimitRule = {
  name: 'Rate Limit for paying users',
  groupBy: { jwt: 'sub' },
  limit: {
    type: 'RequestCount',
    budget: 5_000,
    window: '1m',
  },
}

const freeLimitRule = {
  name: 'Rate Limit for free users',
  groupBy: { jwt: 'sub' },
  limit: {
    type: 'RequestCount',
    budget: 500,
    window: '1m',
  },
}

const unauthenticatedRule = {
  name: 'Rate Limit for unautheticated users (by ip)',
  groupBy: 'ip',
  limit: {
    type: 'RequestCount',
    budget: 500,
    window: '1m',
  },
}

// Using callback
const config: Config = {
  config: {
    rateLimits: (req) => {
      if (req.jwt?.plan === 'pro') {
        return [paidLimitRule]
      }

      if (req.jwt) {
        return [freeLimitRule]
      }

      return [freeLimitRule]
    },
  },
}

// Using consumerIdentifiers
const config: Config = {
  getConsumerIdentifiers: (req) => {
    if (req.jwt?.plan === 'pro') {
      return {
        paidPlan: req.jwt?.sub,
      }
    }

    if (req.jwt?.plan === 'free') {
      return {
        freePlan: req.jwt?.sub,
      }
    }

    return {
      freeIP: req.ip,
    }
  },
  rateLimits: [
    {
      name: 'Rate Limit for paying users',
      groupBy: { consumerIdentifier: 'paidPlan' },
      limit: {
        type: 'RequestCount',
        budget: 500,
        window: '1m',
      },
    },
    {
      name: 'Rate Limit for free users',
      groupBy: { consumerIdentifier: 'paidPlan' },
      limit: {
        type: 'RequestCount',
        budget: 50,
        window: '1m',
      },
    },
    {
      name: 'Rate Limit for unautheticated users (by ip)',
      groupBy: { consumerIdentifier: 'freeIP' },
      limit: {
        type: 'RequestCount',
        budget: 50,
        window: '1m',
      },
    },
  ],
}

export default config

You can read more here about how to debug functions.

Authenticated vs Unauthenticated users

You might want to give authenticated users a higher limit than unauthenticated ones.

import { Config } from 'stellate'

// using callback
const config: Config = {
  config: {
    rateLimits: (req) => {
      if (req.jwt?.sub) {
        return [
          {
            name: 'Rate Limit for authenticated users',
            group: req.jwt.sub,
            limit: {
              type: 'RequestCount',
              budget: 500,
              window: '1m',
            },
          },
        ]
      }

      return [
        {
          name: 'Rate Limit for unautheticated users (by ip)',
          groupBy: 'ip',
          limit: {
            type: 'RequestCount',
            budget: 50,
            window: '1m',
          },
        },
      ]
    },
  }
}
export default config

You can read more here about how to debug functions.

Multiple facets (Users, Orgs, ...)

You can both group by a custom consumer identifier and a JWT scope.

import { Config } from 'stellate'

const byUser = {
  name: 'Individual users within an organization',
  groupBy: { jwt: 'sub' },
  limit: {
    type: 'RequestCount',
    budget: 10,
    window: '1m',
  },
}

const byOrg = {
  name: 'Shared organization limit',
  groupBy: { jwt: 'orgId' },
  limit: {
    type: 'RequestCount',
    budget: 50,
    window: '1m',
  },
}

const config: Config = {
  config: {
    rateLimits: [byUser, byOrg],
  }
}
export default config

Different limits for different consumers

Depending on which client is sending the request, you can utilize JWT to identify them and limit differently.

import { Config } from 'stellate'

const config: Config = {
  rateLimits: (req) => {
    return [
      {
        name: 'Rate Limit based on account',
        group: req.jwt?.sub ?? req.ip,
        limit: {
          type: 'RequestLimit',
          budget: ['clientA', 'clientB'].includes(req.jwt?.orgName) ? 500 : 250,
          window: '1h',
        },
      },
    ]
  },
}
export default config

You can read more here about how to debug functions.

Specific Mutations

If you only want to rate limit specific mutations, this is the way to go.

import { Config } from 'stellate'
import { isMutation, hasRootField } from 'stellate/rate-limiting'

const config: Config = {
  config: {
    rateLimits: (req) => {
      return [
        {
          name: 'Default Rate Limit',
          groupBy: 'ip',
          limit: {
            type: 'RequestCount',
            budget: 50,
            window: '1m',
          },
        },

        // each device (ip) has max 3 login attempts/min
        isMutation(req) && hasRootField(req, 'login')
          ? {
              name: 'Rate Limit for logins',
              groupBy: 'ip',
              limit: {
                type: 'RequestCount',
                budget: 3,
                window: '1m',
              },
            }
          : null,

        // each device (ip) can use 25 max mutations/min
        isMutation(req)
          ? {
              name: 'Rate Limit for mutations',
              groupBy: 'ip',
              limit: {
                type: 'RequestCount',
                budget: 25,
                window: '1m',
              },
            }
          : null,
      ]
    },
  },
}
export default config

You can read more here about how to debug functions.

Different limits for different arguments

You can even use different GraphQL arguments to rate limit differently.

import { Config } from 'stellate'

const config: Config = {
  config: {
    rateLimits: (req) => {
      // each device (ip) has max 3 login attempts/minute
      let specialFieldArgLimit = null
      const hasArgs = req.rootFields.some(
        (field) => field.name === 'someField' && field.args.id === 'specialId',
      )
      if (hasArgs) {
        specialFieldArgLimit = {
          name: 'Special rule based on args',
          groupBy: 'ip',
          limit: {
            type: 'RequestCount',
            budget: 3,
            window: '1m',
          },
        }
      }

      return [
        specialFieldArgLimit,
        {
          name: 'default Rate Limit',
          groupBy: 'ip',
          limit: {
            type: 'QueryComplexity',
            budget: 500,
            window: '1m',
          },
        },
      ]
    },
  },
}
export default config

You can read more here about how to debug functions.

Limiting by number of requests and complexity

Using request count based rules as a baseline and adding query complexity based rate limiting can be a powerful combination.

import { Config } from 'stellate'

const config: Config = {
  config: {
    rateLimits: [
      {
        name: 'Rate Limit for complexity',
        groupBy: 'ip',
        limit: {
          type: 'QueryComplexity',
          budget: 5_000,
          window: '1m',
        },
      },
      {
        name: 'Rate Limit for requests',
        groupBy: 'ip',
        limit: {
          type: 'RequestCount',
          budget: 100,
          window: '1m',
        },
      },
    ],
  }
}
export default config

Additionally, you can set an overall static limit for complexity. These three together give you a strong baseline of protection.

import { Config } from 'stellate'

const config: Config = {
  config: {
    rateLimits: [
      {
        name: 'Rate Limit for complexity',
        groupBy: 'ip',
        limit: {
          type: 'QueryComplexity',
          budget: 5_000,
          window: '1m',
        },
      },
      {
        name: 'Rate Limit for requests',
        groupBy: 'ip',
        limit: {
          type: 'RequestCount',
          budget: 100,
          window: '1m',
        },
      },
    ],
    complexity: {
      maxComplexity: 1000,
    },
  },
}
export default config

Restricting the number of mutations in a single request

A GraphQL request can contain multiple mutations. To disallow malicious users from executing multiple mutations in a single request, you can build a rule with zero budget which is only applied when a specific threshold of mutations is reached.

import { Config, RateLimitRule, EdgeRequest } from 'stellate'
import { isMutation } from 'stellate/rate-limiting'

function hasMultipleMutations(req: EdgeRequest) {
  if (!isMutation(req)) {
    return false
  }

  return req.rootFields.length > 1
}

const blockMultipleMutationsPerRequest: RateLimitRule = {
  name: 'Only 1 mutation per request',
  groupBy: 'ip',
  limit: {
    type: 'RequestCount',
    budget: 0,
    window: '1m',
  },
}

const config: Config = {
  rateLimits: (req) => {
    if (hasMultipleMutations(req)) {
      return [blockMultipleMutationsPerRequest]
    }

    return [
      // ... your actual rules go here
    ]
  },
}
export default config

Setting a global limit across all consumers

Sometimes it can be helpful to set a limit that applies to all users of an API in combination. Let's say you've encountered an issue with your full-text search and while you are working on a fix, you want to prevent your service from being unavailable.

In this example, we assume only authenticated users are accessing the service. We enforce two conditions:

  1. Every user can issue up to five searches per minute
  2. All users together can issue a maximum of 500 searches per minute
import { Config } from 'stellate'
import { isQuery, hasRootField } from 'stellate/rate-limiting'

const authenticatedSearchRule = {
  name: 'Full text search rule (per user)',
  groupBy: { jwt: 'sub' },
  state: 'dryRun',
  limit: {
    type: 'RequestCount',
    window: '1m',
    budget: 5,
  },
}

const globalSeachRule = {
  name: 'Full text search rule (global limit)',
  // Using a static value here so all users contribute to the same limit
  group: 'global-static-this-could-be-anything-be-creative',
  state: 'dryRun',
  limit: {
    type: 'RequestCount',
    window: '1m',
    budget: 500,
  },
}

// We define our own helper to keep the `rateLimits` function easy to read
function usesFullTextSearch(req: EdgeRequest) {
  if (!isQuery(req)) {
    return false
  }

  // Check whether a field is used that allows for full-text search
  const field = req.rootFields.find((f) => f.name === 'products')
  if (!field) {
    return false
  }

  // Check whether full-text search is actually used
  return field.args?.hasOwnProperty('search') ?? false
}

const config: Config = {
  config: {
    scopes: {
      AUTHENTICATED: {
        definition: 'header:authorization',
        jwt: {
          claim: 'sub',
          algorithm: 'HS256',
          secret: 'muchsecretsuchwow',
        },
      },
    },
    rateLimits: (req) => {
      let fullTextSearchRules = []

      if (usesFullTextSearch(req)) {
        fullTextSearchRules.push(globalSeachRule)

        if (req.jwt?.sub) {
          fullTextSearchRules.push(authenticatedSearchRule)
        }
      }

      return [
        {
          name: 'IP limit',
          groupBy: 'ip',
          state: 'dryRun',
          limit: {
            type: 'RequestCount',
            window: '1m',
            budget: 5,
          },
        },

        ...fullTextSearchRules,
      ]
    },
    complexity: {
      maxComplexity: 1000,
      listSizeArguments: ['first', 'last'],
    },
  },
}
export default config

Allow Listing specific identifiers

TODO. In case this is a use-case for you, please reach out to us in Intercom.