QA How-To
GraphQL Query Complexity Security Testing: Block Expensive Queries (2026)
Learn graphql query complexity security testing with runnable Node.js tests that verify depth, aliases, list multipliers, batching, and safe error responses.
18 min read | 2,565 words
TL;DR
Assign costs to GraphQL fields, multiply list fields by bounded pagination arguments, and reject operations whose estimated cost exceeds a server-side maximum. Then automate boundary, depth, alias, list, fragment, and batch tests while proving rejected queries never reach expensive resolvers.
Key Takeaways
- Test query cost, depth, aliases, list sizes, and batching as separate abuse dimensions.
- Assert stable error codes and safe messages instead of matching framework-specific prose.
- Use a small in-process GraphQL server to make security regression tests deterministic.
- Verify that rejected operations do not invoke protected resolvers.
- Keep legitimate boundary queries in the suite so limits do not break real clients.
- Combine complexity controls with timeouts, rate limits, pagination caps, and monitoring.
The goal of graphql query complexity security testing is to prove that a valid but expensive operation cannot consume unbounded CPU, memory, database work, or downstream API capacity. A useful test suite sends hostile query shapes, checks a predictable rejection, and confirms that normal queries near the limit still work.
This tutorial builds that suite against a small Node.js GraphQL server. For broader coverage of schemas, operations, authorization, and observability, start with the Modern GraphQL API Testing Complete Guide.
You will measure more than nesting depth. Aliases, fragments, large list arguments, repeated root fields, and batched HTTP operations can all increase work while remaining syntactically valid.
What You Will Build
By the end, you will have:
- A runnable GraphQL API with a deterministic weighted complexity rule.
- Vitest security tests for allowed, boundary, and rejected operations.
- Attack cases for depth, aliases, fragments, list multipliers, and batching.
- Resolver-call assertions proving rejected operations do no business work.
- Stable client-facing errors that do not leak schema or infrastructure details.
The example uses an in-process server so the suite is fast and repeatable. You can later point the same HTTP assertions at a deployed test environment.
Prerequisites
Use Node.js 22 or a current supported LTS release and npm 10 or newer. Create an empty directory and install the packages:
mkdir graphql-complexity-lab
cd graphql-complexity-lab
npm init -y
npm install graphql @apollo/server express cors graphql-query-complexity
npm install --save-dev vitest supertest
Add ES modules and test scripts to package.json:
{
"type": "module",
"scripts": {
"start": "node src/server.js",
"test": "vitest run",
"test:watch": "vitest"
}
}
The examples deliberately avoid a database. Resolver counters stand in for expensive work and let the tests prove whether execution occurred.
Step 1: Define a Schema With Measurable Work
Create src/schema.js. The schema exposes cheap scalar fields, nested relationships, and paginated lists. Those shapes give the tests several independent ways to create cost.
import { buildSchema } from 'graphql';
export const typeDefs = `#graphql
type Query {
viewer: User!
users(first: Int! = 10): [User!]!
}
type User {
id: ID!
name: String!
friends(first: Int! = 5): [User!]!
}
`;
export const schema = buildSchema(typeDefs);
const people = [
{ id: '1', name: 'Asha' },
{ id: '2', name: 'Noah' },
{ id: '3', name: 'Mina' }
];
export const calls = { viewer: 0, users: 0, friends: 0 };
export const resetCalls = () => {
calls.viewer = 0;
calls.users = 0;
calls.friends = 0;
};
export const rootValue = {
viewer() {
calls.viewer += 1;
return people[0];
},
users({ first }) {
calls.users += 1;
return Array.from({ length: first }, (_, index) =>
people[index % people.length]
);
}
};
export const fieldResolver = (source, args, context, info) => {
if (info.fieldName === 'friends') {
calls.friends += 1;
return Array.from({ length: args.first }, (_, index) =>
people[index % people.length]
);
}
const value = source?.[info.fieldName];
return typeof value === 'function' ? value(args, context, info) : value;
};
A production schema may use Apollo SDL, GraphQL Yoga, Mercurius, or another compliant server. The security principle is the same: estimate work before execution and keep multipliers bounded.
Verify: run node -e "import('./src/schema.js').then(m => console.log(m.schema.getQueryType().name))". The command prints Query. If it fails, confirm that package.json contains "type": "module".
Step 2: Add a Weighted Complexity Gate
Create src/complexity.js. It parses and validates the operation, calculates cost with graphql-query-complexity, and returns a GraphQL error before resolvers execute.
import { GraphQLError, parse, validate } from 'graphql';
import {
fieldExtensionsEstimator,
getComplexity,
simpleEstimator
} from 'graphql-query-complexity';
export const MAX_COMPLEXITY = 100;
export function inspectOperation({ schema, query, variables = {} }) {
let document;
try {
document = parse(query);
} catch (error) {
return { errors: [error] };
}
const validationErrors = validate(schema, document);
if (validationErrors.length > 0) return { errors: validationErrors };
const complexity = getComplexity({
schema,
query: document,
variables,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 })
]
});
if (complexity > MAX_COMPLEXITY) {
return {
complexity,
errors: [new GraphQLError('Operation exceeds the allowed complexity.', {
extensions: { code: 'QUERY_TOO_COMPLEX', maxComplexity: MAX_COMPLEXITY }
})]
};
}
return { complexity, document };
}
The default estimator assigns one point per selected field. That is a useful baseline, but it does not yet multiply list cost by first. You will add that control in Step 5. Keep the public error generic. Log richer diagnostics on the server side with an operation hash, authenticated subject, calculated cost, and request correlation ID.
Verify: temporarily import inspectOperation in a Node command and pass { viewer { id name } }. Its result contains a numeric complexity, a parsed document, and no errors.
Step 3: Reject Expensive Queries Before Execution
Create src/app.js. This compact Express endpoint makes preprocessing explicit, which is valuable for security tests. It accepts one operation object at a time and executes only after inspection succeeds.
import express from 'express';
import { execute } from 'graphql';
import { schema, rootValue, fieldResolver } from './schema.js';
import { inspectOperation } from './complexity.js';
export function createApp() {
const app = express();
app.use(express.json({ limit: '100kb' }));
app.post('/graphql', async (request, response) => {
const { query, variables, operationName } = request.body ?? {};
if (typeof query !== 'string') {
return response.status(400).json({
errors: [{ message: 'A GraphQL query string is required.', extensions: { code: 'BAD_REQUEST' } }]
});
}
const inspection = inspectOperation({ schema, query, variables });
if (inspection.errors) {
return response.status(200).json({
errors: inspection.errors.map(error => ({
message: error.message,
extensions: error.extensions
}))
});
}
const result = await execute({
schema,
document: inspection.document,
rootValue,
variableValues: variables,
operationName,
fieldResolver
});
return response.status(200).json(result);
});
return app;
}
Create src/server.js for manual use:
import { createApp } from './app.js';
createApp().listen(4000, () => {
console.log('GraphQL endpoint: http://localhost:4000/graphql');
});
Returning HTTP 200 for a well-formed GraphQL request with GraphQL-level errors is common. Some gateways use 400 or 429 for policy rejection. Choose one documented contract and test the error code in extensions, not only the HTTP status.
Verify: start the server with npm start. Send curl -s http://localhost:4000/graphql -H 'content-type: application/json' --data '{"query":"{ viewer { id name } }"}'. The response contains Asha and no errors.
Step 4: Automate GraphQL Query Complexity Security Testing
Create test/complexity.test.js. These tests establish the safe path, the exact boundary behavior, and the first rejected case. Repetition through aliases creates a deterministic cost without relying on timing.
import { beforeEach, describe, expect, test } from 'vitest';
import request from 'supertest';
import { createApp } from '../src/app.js';
import { calls, resetCalls } from '../src/schema.js';
const app = createApp();
function aliasedViewer(count) {
const fields = Array.from({ length: count }, (_, index) =>
`v${index}: viewer { id }`
).join('\n');
return `query AliasLoad { ${fields} }`;
}
async function post(query, variables) {
return request(app).post('/graphql').send({ query, variables });
}
describe('query complexity policy', () => {
beforeEach(resetCalls);
test('allows an ordinary operation', async () => {
const response = await post('{ viewer { id name } }');
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.viewer.name).toBe('Asha');
expect(calls.viewer).toBe(1);
});
test('allows cost exactly equal to the maximum', async () => {
const response = await post(aliasedViewer(50));
expect(response.body.errors).toBeUndefined();
expect(calls.viewer).toBe(50);
});
test('rejects cost above the maximum before execution', async () => {
const response = await post(aliasedViewer(51));
expect(response.body.data).toBeUndefined();
expect(response.body.errors[0].extensions.code).toBe('QUERY_TOO_COMPLEX');
expect(response.body.errors[0].extensions.maxComplexity).toBe(100);
expect(calls.viewer).toBe(0);
});
});
Each alias costs two points: one for viewer and one for id. Fifty aliases cost exactly 100 and 51 cost 102. This is why a boundary test matters. It catches accidental changes from > to >= and documents whether the configured maximum is inclusive.
Verify: run npm test. Vitest reports three passing tests. The strongest assertion is calls.viewer === 0, because a rejection that happens after resolver execution does not protect resources.
Step 5: Weight Lists by Pagination Arguments
A flat field count underestimates lists. Ten selected fields returned once are not equivalent to ten fields returned for 1,000 objects. Add complexity extensions immediately after the buildSchema(typeDefs) line in src/schema.js. The extensions use the requested size, so an oversized value cannot look artificially cheap during pre-execution analysis:
const usersField = schema.getQueryType().getFields().users;
usersField.extensions = {
...usersField.extensions,
complexity: ({ args, childComplexity }) =>
2 + args.first * childComplexity
};
const friendsField = schema.getType('User').getFields().friends;
friendsField.extensions = {
...friendsField.extensions,
complexity: ({ args, childComplexity }) =>
2 + args.first * childComplexity
};
Complexity calculation is not input validation. Enforce first < 1, users.first > 50, and friends.first > 20 in the resolvers with a stable BAD_USER_INPUT error. The estimator must still use the original requested value, not a capped value, so an oversized query is rejected before execution instead of receiving an artificially low cost. Use a shared validation helper before allocating either list, and never silently authorize more resolver work than the estimator counted.
Add these tests:
test('accounts for a list multiplier', async () => {
const response = await post('{ users(first: 50) { id name } }');
expect(response.body.errors[0].extensions.code).toBe('QUERY_TOO_COMPLEX');
expect(calls.users).toBe(0);
});
test('uses variable values during cost calculation', async () => {
const query = 'query Users($size: Int!) { users(first: $size) { id } }';
const allowed = await post(query, { size: 20 });
expect(allowed.body.errors).toBeUndefined();
resetCalls();
const rejected = await post(query, { size: 1000 });
expect(rejected.body.errors[0].extensions.code).toBe('QUERY_TOO_COMPLEX');
expect(calls.users).toBe(0);
});
List multipliers make the model closer to real work, but they remain estimates. Assign higher base weights to fields that call search services, generate files, perform joins, or fan out to remote APIs. Review weights with the teams that own those dependencies.
Verify: run npm test. The 50-user selection is rejected before users runs. The smaller variable-driven query succeeds, proving the estimator reads coerced runtime variables rather than only query text.
Step 6: Test Depth, Fragments, and Alias Overloading
Complexity and depth are related but different controls. A shallow query can be expensive through aliases, while a narrow query can be deeply recursive. Add an independent depth rule if your schema contains cycles such as User.friends.
A practical depth visitor should expand fragment spreads, ignore introspection fields according to your policy, detect fragment cycles through standard GraphQL validation, and calculate each operation separately. Do not count braces with a regular expression. Parse the document into an AST. Libraries such as GraphQL Armor can supply depth and cost plugins, but verify their behavior against your server version.
Add representative regression cases even if the server currently rejects them by total cost:
test('rejects nested list amplification', async () => {
const query = `
query NestedLoad {
users(first: 10) {
friends(first: 10) {
friends(first: 10) { id name }
}
}
}
`;
const response = await post(query);
expect(response.body.errors[0].extensions.code).toBe('QUERY_TOO_COMPLEX');
expect(calls.users).toBe(0);
expect(calls.friends).toBe(0);
});
test('counts fields reached through reusable fragments', async () => {
const query = `
query FragmentLoad {
${Array.from({ length: 51 }, (_, i) => `v${i}: viewer { ...Card }`).join('\n')}
}
fragment Card on User { id }
`;
const response = await post(query);
expect(response.body.errors[0].extensions.code).toBe('QUERY_TOO_COMPLEX');
expect(calls.viewer).toBe(0);
});
The estimator traverses fragment selections, so hiding fields behind a named fragment does not reduce calculated cost. Also test inline fragments and interfaces if your production schema uses them. Alias tests should vary names, because a cache keyed only by raw operation text may behave differently from an operation-normalization cache.
Verify: run the targeted file with npx vitest run test/complexity.test.js. Both cases return QUERY_TOO_COMPLEX, and every resolver counter remains zero.
Step 7: Block Batch Amplification
A client can place several individually acceptable operations in one HTTP request if the server or gateway supports JSON array batching. Complexity enforcement per operation is insufficient when the whole batch creates excessive aggregate cost. Decide whether to disable batching, limit the number of entries, or calculate an aggregate budget.
The tutorial endpoint rejects arrays because it requires an object body. Make that contract explicit near the top of the route:
if (Array.isArray(request.body)) {
return response.status(400).json({
errors: [{
message: 'Batched GraphQL requests are not supported.',
extensions: { code: 'BATCHING_NOT_ALLOWED' }
}]
});
}
Add a test that prevents a future middleware change from enabling batching accidentally:
test('rejects HTTP array batching', async () => {
const operation = { query: '{ viewer { id } }' };
const response = await request(app)
.post('/graphql')
.send(Array.from({ length: 20 }, () => operation));
expect(response.status).toBe(400);
expect(response.body.errors[0].extensions.code).toBe('BATCHING_NOT_ALLOWED');
expect(calls.viewer).toBe(0);
});
GraphQL aliases, HTTP batching, and persisted-query batching are separate mechanisms. Test each mechanism your stack enables. The GraphQL batched requests testing examples cover aggregate limits, partial responses, ordering, and authorization isolation in more depth.
Verify: run npm test. The batch receives HTTP 400 and BATCHING_NOT_ALLOWED; the resolver counter remains zero. Then send a single operation again to prove the endpoint still accepts the intended request shape.
Step 8: Harden Errors, Observability, and Regression Data
Security controls must be diagnosable without helping an attacker map expensive fields. Return a stable code and the public limit only if exposing that limit is part of your API contract. Do not return SQL, stack traces, resolver names, internal service URLs, or raw exception objects.
Use this test to lock the safe response contract:
test('does not leak internals in complexity errors', async () => {
const response = await post(aliasedViewer(51));
const serialized = JSON.stringify(response.body);
expect(serialized).not.toMatch(/stack|database|sql|localhost|resolver/i);
expect(response.body.errors).toHaveLength(1);
expect(response.body.errors[0]).toMatchObject({
message: 'Operation exceeds the allowed complexity.',
extensions: { code: 'QUERY_TOO_COMPLEX' }
});
});
On the server, emit structured metrics for accepted cost, rejected cost, operation name, persisted-query hash, tenant, and policy version. Never use raw query text as a high-cardinality metric label. Sample or securely log it only when your privacy and retention rules permit. Alert on rejection-rate changes and sustained near-limit traffic, not on a single rejected request.
Maintain a corpus of real production operations. Replay it whenever schema weights or limits change. Schema evolution can alter cost unexpectedly, so pair the suite with GraphQL deprecation validation using schema diff. If subscriptions are enabled, test their initial operation cost and ongoing event workload separately with the GraphQL subscriptions and WebSockets tutorial.
Verify: run the full suite and inspect one rejected response. It contains only the generic message and documented extensions. In a test environment, confirm one structured rejection event appears with the same request correlation ID.
GraphQL Query Complexity Security Testing Coverage Matrix
Use a matrix so the suite does not collapse into one oversized query test. Each abuse dimension needs an allowed case, a rejected case, and proof of no execution.
| Risk | Test input | Expected control | Strong verification |
|---|---|---|---|
| Wide selection | Many scalar fields | Weighted cost | Stable error code |
| Alias overload | Repeated root aliases | Cost plus rate limit | Root resolver count is zero |
| Deep recursion | Nested cyclic relationships | Depth and cost limits | Nested resolvers are zero |
| List amplification | Large first or limit |
Input cap and multiplier | Variables affect cost |
| Fragment expansion | Reused named fragments | AST-aware analysis | Expanded fields are counted |
| HTTP batching | JSON array payload | Disable or aggregate cap | Entire batch is rejected |
| Multi-operation document | Several named operations | Analyze selected operation | Correct operation is evaluated |
| Introspection abuse | Large schema query | Policy plus rate limit | Authorized tooling still works |
Do not use response time as the only assertion. Timing varies across machines and may pass even after expensive work begins. Resolver spies, mocked downstream clients, database query counters, and trace assertions provide stronger evidence. Add a generous timeout test only as a secondary defense.
Troubleshooting
The expensive query still executes -> Ensure inspection runs after parsing and validation but before execute. In framework plugins, confirm the hook actually aborts execution rather than only reporting cost. Assert resolver and downstream mock call counts.
Variables do not change calculated cost -> Pass variables into the complexity library and define estimators that read args. Add tests using both literals and variables. Reject missing, negative, and oversized pagination values independently.
Fragments appear to cost less -> Use an AST-aware estimator that expands fragment spreads. Validate the document first so undefined or cyclic fragments fail safely. Do not estimate cost from raw string length.
Legitimate mobile queries are rejected -> Capture the calculated cost distribution of known operations in a non-production replay. Adjust field weights or split a costly operation, then retain a regression fixture at the intended boundary. Do not simply raise the global limit without inspecting downstream work.
Introspection fails for developer tools -> Decide on an environment and identity-based introspection policy. A blanket production exception can become an abuse path. Give trusted tooling authenticated access, rate-limit it, and include its actual cost in tests.
The test passes locally but the gateway behaves differently -> Test both the application and the deployed path. A CDN, API gateway, persisted-query layer, or GraphQL router may batch, normalize, cache, or reject requests before they reach the service. Correlate logs across layers.
Best Practices
- Keep complexity estimation deterministic and perform it before resolver execution.
- Cap pagination arguments through validation as well as cost calculation.
- Weight fields by actual fan-out and dependency cost, then review weights when resolvers change.
- Combine cost limits with maximum depth, request-size limits, deadlines, concurrency controls, authentication, and per-subject rate limits.
- Test exact-boundary behavior and one unit above it.
- Record resolver or downstream call counts for every rejection test.
- Analyze the selected named operation correctly when a document contains several operations.
- Version the cost policy and replay representative client operations before rollout.
- Keep public errors stable and generic while recording actionable internal telemetry.
- Apply separate budgets for mutations and subscriptions when their risk differs from ordinary queries.
Avoid treating complexity as a complete denial-of-service defense. A cheap-looking field may trigger an expensive cache miss, a malicious user may distribute requests, and a subscription may produce ongoing work after a low-cost handshake. Layer the controls and observe actual resource consumption. Add API authorization test cases to verify that cost controls cannot weaken object-level access checks, and use API contract testing with OpenAPI for the REST endpoints and gateway policies that surround your GraphQL service.
Interview Questions and Answers
The interview answers below focus on decisions a senior QA or SDET should be able to defend. Practice explaining the control, the test oracle, and the failure evidence rather than only naming a library.
Q: Why is a GraphQL depth limit not enough?
A shallow operation can repeat expensive fields through aliases or request huge lists. Depth limits recursive shapes, while weighted complexity estimates total work. Use both, plus pagination caps and rate limits.
Q: What is the best assertion for a rejected expensive query?
Assert the documented error code and prove that no resolver or downstream dependency was called. HTTP status and latency alone do not prove early rejection. Also verify the response does not leak internals.
Q: How do variables affect query cost?
Pagination and filter variables can multiply resolver work. The estimator must receive runtime variable values and calculate cost from coerced arguments. Tests should compare the same query document with small and oversized variable values.
Q: How should batch complexity be calculated?
Enforce both a per-operation maximum and an aggregate request budget, or disable batching. Also cap the number and byte size of operations. A batch of individually safe operations can still exhaust shared resources.
Q: How do you choose the maximum complexity?
Model fields using expected fan-out and dependency cost, then replay representative operations. Set the threshold above legitimate needs with operational headroom, observe resource use, and revise through a versioned change. There is no universal safe number.
Q: Should the API reveal calculated complexity?
Usually keep detailed calculation in authenticated tooling and internal telemetry. A stable public code is enough for most clients. If the maximum is documented, exposing it can help clients adapt, but never expose resolver internals or stack traces.
Where To Go Next
You now have a focused regression suite for aliases, fragments, depth-shaped load, list amplification, and HTTP batching. Expand it into a full API strategy with the complete modern GraphQL testing guide.
Next, use the GraphQL batched request test patterns to test aggregate budgets and mixed results. Add subscription and WebSocket security tests for connection limits and event fan-out, then protect client migrations with GraphQL schema deprecation diff testing.
Conclusion
Effective graphql query complexity security testing verifies policy and execution behavior together. Calculate weighted cost from the parsed operation and its variables, reject above-limit work before execution, and assert that protected resolvers never run.
Start with the runnable boundary tests, then add real operations from your clients and weights based on observed dependency cost. Complexity limits work best as one layer alongside depth controls, pagination validation, batching rules, deadlines, rate limits, and production telemetry.
Interview Questions and Answers
Why is a GraphQL depth limit not enough?
Depth measures nesting, not total work. A shallow query can repeat expensive root fields with aliases or request huge lists. I use depth and weighted complexity together, then add pagination caps and rate limits.
What is the strongest test oracle for query complexity rejection?
I assert the stable policy error and verify that resolver or downstream dependency call counts are zero. That proves rejection occurred before execution. I also check that the response contains no stack trace or infrastructure details.
How do variables affect GraphQL query cost?
Variables often control list sizes and filters, so the same document can have very different cost. The estimator must receive runtime variables and calculate from coerced arguments. I test the same operation with allowed, boundary, and oversized values.
How would you test GraphQL alias overloading?
I generate many uniquely aliased selections of an expensive field and test just below, at, and above the limit. For the rejected request, I assert the root resolver was never called. I also combine this with per-subject rate-limit tests.
How should a server handle complexity for batched GraphQL requests?
It should enforce a per-operation limit and an aggregate request budget, or disable batching. I also cap operation count and payload bytes. Tests must prove a batch of individually valid operations cannot bypass the aggregate control.
How do you select field weights and a maximum complexity?
I estimate fan-out and dependency cost with resolver owners, then replay representative production operations. I set the limit above legitimate requirements with operational headroom and validate resource use in a controlled environment. The policy is versioned and monitored because resolver behavior changes.
What controls should accompany GraphQL query complexity analysis?
I add depth limits, strict pagination validation, request-size caps, deadlines, rate limits, concurrency controls, authentication, and monitoring. For subscriptions I separately limit connections and event fan-out. Complexity is an estimate, so layered defenses are necessary.
Frequently Asked Questions
What is GraphQL query complexity testing?
It verifies that a GraphQL server estimates operation cost and rejects requests above a configured budget before execution. Good tests cover fields, aliases, fragments, list multipliers, variables, depth, and batching.
What is a safe GraphQL query complexity limit?
There is no universal safe number because schemas and resolver costs differ. Build weights from expected fan-out, replay representative client operations, and choose a threshold with operational headroom.
Are GraphQL depth limits enough to prevent denial of service?
No. A shallow query can repeat expensive fields with aliases or request very large lists. Combine depth limits with weighted cost, pagination caps, rate limiting, timeouts, and concurrency controls.
Should an expensive GraphQL query return HTTP 400 or 200?
Both conventions exist, so define and document one contract. Clients and tests should primarily rely on a stable GraphQL error extension such as `QUERY_TOO_COMPLEX`, while also checking the chosen HTTP status.
How do I test that complexity rejection happens before resolvers?
Spy on resolvers or mocked downstream clients, send an above-limit operation, and assert their call counts remain zero. This is stronger than a response-time assertion because it directly proves expensive work did not start.
How should GraphQL lists affect query cost?
Multiply the child selection cost by a bounded pagination argument such as `first`. Validate the argument separately so the resolver cannot fetch more records than the estimator assumes.
Can GraphQL fragments bypass complexity limits?
They should not if the estimator traverses the parsed GraphQL AST and expands fragment spreads. Add named-fragment and inline-fragment tests to prevent regressions or framework configuration mistakes.
Related Guides
- GraphQL API testing: A Practical Guide (2026)
- API error handling and negative testing: A Practical Guide (2026)
- API idempotency testing: A Practical Guide (2026)
- API pagination testing: A Practical Guide (2026)
- API rate limiting testing: A Practical Guide (2026)
- API security testing basics: A Practical Guide (2026)