GraphQL Endpoint Discovery and Security Analysis: A Developer's Guide
A practical Fusebox guide to graphql endpoint discovery and security analysis.
GraphQL Endpoint Discovery and Security Analysis: A Developer's Guide
GraphQL has revolutionized API design, but its flexibility introduces unique security challenges. While REST APIs have predictable endpoints, GraphQL's single endpoint and introspection capabilities create both opportunities and risks. This guide will help you discover GraphQL endpoints, analyze their security posture, and identify potential vulnerabilities.
Why GraphQL Security Matters
In 2021, a major e-commerce platform exposed sensitive customer data through an unrestricted GraphQL introspection query. The flexible nature of GraphQL allowed attackers to craft queries that bypassed rate limiting and extracted millions of records. Understanding GraphQL security is crucial for both protecting your APIs and responsibly testing third-party services.
Quick GraphQL Endpoint Detection
Run this in your browser console to detect GraphQL endpoints:
// Detect GraphQL endpoints on current website
async function detectGraphQL() {
const commonEndpoints = [
'/graphql', '/api/graphql', '/v1/graphql', '/query',
'/api', '/api/v1', '/graphiql', '/playground',
'/altair', '/voyager', '/graphql-explorer'
];
const results = [];
for (const endpoint of commonEndpoints) {
try {
// Try introspection query
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: '{ __schema { types { name } } }'
})
});
if (response.ok) {
const data = await response.json();
if (data.data && data.data.__schema) {
results.push({
endpoint,
introspectionEnabled: true,
status: 'Found with introspection'
});
}
} else if (response.status === 400) {
// GraphQL endpoint exists but introspection might be disabled
const text = await response.text();
if (text.includes('query') || text.includes('graphql')) {
results.push({
endpoint,
introspectionEnabled: false,
status: 'Found but introspection disabled'
});
}
}
} catch (error) {
// Network error or CORS - might still exist
}
}
// Check for GraphQL indicators in page
const pageIndicators = {
apolloClient: typeof window.__APOLLO_CLIENT__ !== 'undefined',
graphiQL: document.querySelector('[class*="graphiql"]') !== null,
relayModern: typeof window.__RELAY_MODERNIZE_FORCE_SPLIT__ !== 'undefined'
};
return { endpoints: results, indicators: pageIndicators };
}
detectGraphQL().then(console.log);
Introspection Query Analysis
GraphQL introspection allows clients to query the schema. Here's how to analyze it:
// Full introspection query for schema analysis
async function analyzeGraphQLSchema(endpoint) {
const introspectionQuery = `
query IntrospectionQuery {
__schema {
queryType { name }
mutationType { name }
subscriptionType { name }
types {
...FullType
}
directives {
name
description
locations
args {
...InputValue
}
}
}
}
fragment FullType on __Type {
kind
name
description
fields(includeDeprecated: true) {
name
description
args {
...InputValue
}
type {
...TypeRef
}
isDeprecated
deprecationReason
}
inputFields {
...InputValue
}
interfaces {
...TypeRef
}
enumValues(includeDeprecated: true) {
name
description
isDeprecated
deprecationReason
}
possibleTypes {
...TypeRef
}
}
fragment InputValue on __InputValue {
name
description
type { ...TypeRef }
defaultValue
}
fragment TypeRef on __Type {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
}
}
}
}
}`;
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: introspectionQuery })
});
const data = await response.json();
if (data.data && data.data.__schema) {
const schema = data.data.__schema;
// Analyze schema for security issues
const analysis = {
introspectionEnabled: true,
totalTypes: schema.types.length,
customTypes: schema.types.filter(t => !t.name.startsWith('__')).length,
queries: [],
mutations: [],
subscriptions: [],
deprecatedFields: [],
sensitiveFields: []
};
// Find queries, mutations, subscriptions
schema.types.forEach(type => {
if (type.name === schema.queryType.name && type.fields) {
analysis.queries = type.fields.map(f => f.name);
}
if (schema.mutationType && type.name === schema.mutationType.name && type.fields) {
analysis.mutations = type.fields.map(f => f.name);
}
if (schema.subscriptionType && type.name === schema.subscriptionType.name && type.fields) {
analysis.subscriptions = type.fields.map(f => f.name);
}
// Find deprecated fields
if (type.fields) {
type.fields.forEach(field => {
if (field.isDeprecated) {
analysis.deprecatedFields.push({
type: type.name,
field: field.name,
reason: field.deprecationReason
});
}
// Check for potentially sensitive fields
const sensitivePatterns = [
'password', 'secret', 'token', 'key', 'auth',
'credit', 'ssn', 'private', 'confidential'
];
if (sensitivePatterns.some(pattern =>
field.name.toLowerCase().includes(pattern))) {
analysis.sensitiveFields.push({
type: type.name,
field: field.name
});
}
});
}
});
return analysis;
}
return { introspectionEnabled: false, error: 'Introspection disabled' };
} catch (error) {
return { error: error.message };
}
}
Query Depth and Complexity Analysis
GraphQL's nested nature can lead to expensive queries. Here's how to test for depth limits:
// Test query depth limits
async function testQueryDepth(endpoint) {
// Build a deeply nested query
function buildNestedQuery(depth) {
let query = '{ users { id name';
let closing = '} }';
for (let i = 0; i < depth; i++) {
query += ' friends { id name';
closing += ' }';
}
return query + closing;
}
const results = [];
const depths = [5, 10, 15, 20, 30, 50];
for (const depth of depths) {
try {
const query = buildNestedQuery(depth);
const start = performance.now();
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query })
});
const end = performance.now();
const responseData = await response.json();
results.push({
depth,
status: response.status,
time: end - start,
error: responseData.errors ? responseData.errors[0].message : null,
blocked: response.status === 400 && responseData.errors
});
// Stop if we hit a limit
if (results[results.length - 1].blocked) {
break;
}
} catch (error) {
results.push({ depth, error: error.message });
}
}
return {
maxDepthTested: Math.max(...results.filter(r => !r.blocked).map(r => r.depth)),
depthLimitFound: results.find(r => r.blocked)?.depth || 'No limit found',
results
};
}
Batching and Rate Limit Testing
Test for query batching vulnerabilities:
// Test batching and rate limits
async function testBatching(endpoint) {
// Test array batching
const arrayBatch = Array(10).fill({
query: '{ __typename }'
});
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(arrayBatch)
});
const data = await response.json();
const arrayBatchingEnabled = Array.isArray(data);
// Test alias batching
const aliasBatch = {
query: `{
${Array(10).fill().map((_, i) =>
`query${i}: __typename`
).join('\n')}
}`
};
const aliasResponse = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(aliasBatch)
});
const aliasData = await aliasResponse.json();
const aliasCount = aliasData.data ? Object.keys(aliasData.data).length : 0;
return {
arrayBatchingEnabled,
aliasBatchingEnabled: aliasCount > 1,
maxAliasesInQuery: aliasCount,
potentialDoS: arrayBatchingEnabled || aliasCount > 5
};
} catch (error) {
return { error: error.message };
}
}
Field Suggestion Exploitation
GraphQL's helpful error messages can leak schema information:
// Test field suggestion information leakage
async function testFieldSuggestions(endpoint) {
const queries = [
{ query: '{ usr { id } }', testing: 'user suggestion' },
{ query: '{ users { passwrd } }', testing: 'password field' },
{ query: '{ users { secretToken } }', testing: 'token field' },
{ query: '{ admin { id } }', testing: 'admin type' }
];
const leakedInfo = [];
for (const test of queries) {
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: test.query })
});
const data = await response.json();
if (data.errors && data.errors[0].message) {
const message = data.errors[0].message;
// Check for field suggestions
if (message.includes('Did you mean')) {
const suggested = message.match(/"([^"]+)"/g);
if (suggested) {
leakedInfo.push({
tested: test.testing,
revealed: suggested.map(s => s.replace(/"/g, '')),
fullError: message
});
}
}
}
} catch (error) {
// Continue testing
}
}
return leakedInfo;
}
Authentication and Authorization Testing
Test GraphQL authentication and authorization:
// Test authentication and authorization
async function testGraphQLAuth(endpoint) {
const tests = {
// Test without auth
noAuth: async () => {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: '{ users { id email } }'
})
});
return {
status: response.status,
hasData: (await response.json()).data !== null
};
},
// Test with fake token
fakeAuth: async () => {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer fake-token-12345'
},
body: JSON.stringify({
query: '{ me { id email } }'
})
});
return {
status: response.status,
authenticated: response.status !== 401
};
},
// Test JWT none algorithm
jwtNone: async () => {
// Create JWT with none algorithm
const header = btoa(JSON.stringify({ alg: 'none', typ: 'JWT' }));
const payload = btoa(JSON.stringify({ sub: '1234567890', admin: true }));
const noneToken = `${header}.${payload}.`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${noneToken}`
},
body: JSON.stringify({
query: '{ users { id email } }'
})
});
return {
status: response.status,
noneAlgorithmAccepted: response.status === 200
};
}
};
const results = {};
for (const [name, test] of Object.entries(tests)) {
try {
results[name] = await test();
} catch (error) {
results[name] = { error: error.message };
}
}
return results;
}
Complete GraphQL Security Audit
Here's a comprehensive security audit function:
// Complete GraphQL security audit
async function auditGraphQLSecurity(endpoint = '/graphql') {
console.log('Starting GraphQL Security Audit...');
const audit = {
endpoint,
timestamp: new Date().toISOString(),
vulnerabilities: [],
recommendations: []
};
// 1. Introspection check
console.log('Checking introspection...');
const schema = await analyzeGraphQLSchema(endpoint);
audit.introspection = schema;
if (schema.introspectionEnabled) {
audit.vulnerabilities.push({
severity: 'Medium',
type: 'Information Disclosure',
description: 'GraphQL introspection is enabled in production'
});
audit.recommendations.push('Disable introspection in production environments');
}
// 2. Depth limit check
console.log('Testing query depth limits...');
const depthTest = await testQueryDepth(endpoint);
audit.depthLimits = depthTest;
if (depthTest.maxDepthTested > 10) {
audit.vulnerabilities.push({
severity: 'High',
type: 'DoS',
description: `No query depth limit found (tested up to ${depthTest.maxDepthTested})`
});
audit.recommendations.push('Implement query depth limiting (recommended: 7-10 levels)');
}
// 3. Batching check
console.log('Testing batching...');
const batchingTest = await testBatching(endpoint);
audit.batching = batchingTest;
if (batchingTest.arrayBatchingEnabled) {
audit.vulnerabilities.push({
severity: 'Medium',
type: 'DoS',
description: 'Array batching is enabled without apparent limits'
});
audit.recommendations.push('Implement batch query limits or disable array batching');
}
// 4. Field suggestion check
console.log('Testing field suggestions...');
const suggestions = await testFieldSuggestions(endpoint);
audit.fieldSuggestions = suggestions;
if (suggestions.length > 0) {
audit.vulnerabilities.push({
severity: 'Low',
type: 'Information Disclosure',
description: 'Field suggestions reveal schema information'
});
audit.recommendations.push('Consider disabling field suggestions in production');
}
// 5. Authentication check
console.log('Testing authentication...');
const authTest = await testGraphQLAuth(endpoint);
audit.authentication = authTest;
if (authTest.noAuth && authTest.noAuth.hasData) {
audit.vulnerabilities.push({
severity: 'Critical',
type: 'Authentication Bypass',
description: 'GraphQL endpoint returns data without authentication'
});
audit.recommendations.push('Implement proper authentication for all queries');
}
// 6. Check for sensitive fields in schema
if (schema.sensitiveFields && schema.sensitiveFields.length > 0) {
audit.vulnerabilities.push({
severity: 'High',
type: 'Sensitive Data Exposure',
description: `Found ${schema.sensitiveFields.length} potentially sensitive fields exposed`
});
audit.recommendations.push('Review and restrict access to sensitive fields');
}
// Generate security score
const severityScores = {
Critical: 40,
High: 20,
Medium: 10,
Low: 5
};
const totalScore = audit.vulnerabilities.reduce((score, vuln) =>
score - severityScores[vuln.severity], 100);
audit.securityScore = Math.max(0, totalScore);
audit.summary = {
totalVulnerabilities: audit.vulnerabilities.length,
critical: audit.vulnerabilities.filter(v => v.severity === 'Critical').length,
high: audit.vulnerabilities.filter(v => v.severity === 'High').length,
medium: audit.vulnerabilities.filter(v => v.severity === 'Medium').length,
low: audit.vulnerabilities.filter(v => v.severity === 'Low').length
};
console.log('GraphQL Security Audit Complete!');
return audit;
}
// Run the audit
auditGraphQLSecurity('/graphql').then(console.log);
Cost Analysis Attack Detection
Detect potential cost analysis attacks:
// Detect cost analysis attack potential
function detectCostAnalysisAttack(schema) {
const expensivePatterns = [];
schema.types.forEach(type => {
if (type.fields) {
type.fields.forEach(field => {
// Check for list types that could be expensive
if (field.type && field.type.kind === 'LIST') {
// Check if this list field has nested lists
let hasNestedList = false;
let fieldType = field.type.ofType;
while (fieldType) {
if (fieldType.kind === 'LIST') {
hasNestedList = true;
break;
}
fieldType = fieldType.ofType;
}
if (hasNestedList) {
expensivePatterns.push({
type: type.name,
field: field.name,
pattern: 'Nested lists - potential O(n²) or worse complexity'
});
}
}
// Check for circular references
const relatedType = schema.types.find(t =>
t.name === (field.type.name || (field.type.ofType && field.type.ofType.name))
);
if (relatedType && relatedType.fields) {
const hasCircular = relatedType.fields.some(f => {
const typeName = f.type.name || (f.type.ofType && f.type.ofType.name);
return typeName === type.name;
});
if (hasCircular) {
expensivePatterns.push({
type: type.name,
field: field.name,
pattern: `Circular reference with ${relatedType.name}`
});
}
}
});
}
});
return expensivePatterns;
}
Best Practices for GraphQL Security
- Disable Introspection in Production: Never expose your schema structure
- Implement Query Depth Limiting: Prevent deeply nested queries (max 7-10 levels)
- Add Query Complexity Analysis: Calculate and limit query complexity scores
- Rate Limiting: Implement per-user or per-IP rate limits
- Query Whitelisting: In high-security environments, only allow pre-approved queries
- Timeout Protection: Set aggressive timeouts for query execution
- Authentication: Require authentication for all non-public data
- Field-Level Authorization: Implement authorization at the field resolver level
- Audit Logging: Log all queries, especially those that fail authorization
- Input Validation: Validate and sanitize all input variables
Common GraphQL Vulnerabilities to Check
- Introspection Enabled: Information disclosure about API structure
- No Depth Limiting: DoS through deeply nested queries
- No Query Complexity Analysis: DoS through complex queries
- Batch Query Attacks: DoS through array or alias batching
- Missing Authentication: Unauthorized data access
- Information Disclosure: Through error messages and suggestions
- Injection Attacks: Through unvalidated input variables
- Cost Analysis Attacks: Expensive queries causing resource exhaustion
Remember: GraphQL's power comes with responsibility. Its flexibility makes it crucial to implement proper security controls. Always test with permission and use these techniques to improve your API security, not to exploit others' systems.