Rate Limiting Cookbook
Stellate Rate Limiting offers different types of limitations you can configure. The following sections describe various "recipes" or instructions for using Rate Limiting.
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 has 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:
To use this example, you will need to have JWT tokens configured.
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.
To use this example, you will need to have JWT tokens configured.
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.
To use this example, you will need to have JWT tokens configured.
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.
To use this example, you will need to have JWT tokens configured.
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:
- Every user can issue up to five searches per minute
- All users together can issue a maximum of 500 searches per minute
To use this example, you will need to have JWT tokens configured.
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 Crisp.