Back to blog
SecurityJanuary 1, 2024· 9 min read

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

  1. Disable Introspection in Production: Never expose your schema structure
  2. Implement Query Depth Limiting: Prevent deeply nested queries (max 7-10 levels)
  3. Add Query Complexity Analysis: Calculate and limit query complexity scores
  4. Rate Limiting: Implement per-user or per-IP rate limits
  5. Query Whitelisting: In high-security environments, only allow pre-approved queries
  6. Timeout Protection: Set aggressive timeouts for query execution
  7. Authentication: Require authentication for all non-public data
  8. Field-Level Authorization: Implement authorization at the field resolver level
  9. Audit Logging: Log all queries, especially those that fail authorization
  10. 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.