All skills
apollographql avatar

/graphql-schema

@79d2b07 official
by Apollo GraphQLapollographql/skills115 stars
13

Guide for designing and changing GraphQL schemas following industry best practices. Use this skill whenever you create or edit GraphQL SDL, such as a `schema.graphql` file or `typeDefs`, including when: (1) designing a new GraphQL schema or API, (2) adding or changing types, fields, arguments, mutations, or descriptions in an existing schema, (3) reviewing existing schema for improvements, (4) deciding on type structures or nullability, (5) implementing pagination or error patterns, (6) ensuring security in schema design.

Use this Skill: https://skilld.dev/gh/apollographql/skills/graphql-schema

This session only. Nothing lands on disk.

referencessecurity.md

≈2.3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Security Best Practices

This reference covers security considerations for GraphQL schema design.

Table of Contents

Introspection

Disable in Production

Introspection reveals your entire schema. Disable it in production:

// Apollo Server
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
});

Allow for Development

Keep introspection enabled for:

  • Development environments
  • Internal tools
  • Authorized clients (with authentication)
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV === 'development' ||
                 process.env.ENABLE_INTROSPECTION === 'true',
});

Query Complexity

Why Limit Complexity

A single query can request enormous amounts of data:

# Potentially very expensive
query {
  users(first: 1000) {
    posts(first: 1000) {
      comments(first: 1000) {
        author {
          posts(first: 1000) {
            # ... could go deeper
          }
        }
      }
    }
  }
}

Complexity Calculation

Assign costs to fields and limit total cost:

type Query {
  users(first: Int): [User!]! @cost(complexity: 10, multipliers: ["first"])
}

type User {
  posts(first: Int): [Post!]! @cost(complexity: 5, multipliers: ["first"])
}

Implementation with graphql-validation-complexity

import { createComplexityLimitRule } from 'graphql-validation-complexity';

const complexityLimitRule = createComplexityLimitRule(1000, {
  scalarCost: 1,
  objectCost: 10,
  listFactor: 10,
});

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [complexityLimitRule],
});

Cost Estimation in Schema

Document expected costs:

"""
Returns user's posts.
Cost: Base 5 + (first * 2)
"""
type User {
  posts(
    first: Int = 20 @cost(weight: 2)
  ): PostConnection! @cost(complexity: 5)
}

Depth Limiting

Why Limit Depth

Prevent deeply nested queries:

# Depth: 10+ levels deep
query {
  user {
    friends {
      friends {
        friends {
          friends {
            # ...
          }
        }
      }
    }
  }
}

Implementation

import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(10)],
});

Recommended Limits

Application Type Max Depth
Simple API 5-7
Complex API 7-10
Internal tools 10-15

Rate Limiting

Query-Based Rate Limiting

Limit queries per time window:

// Example with express-rate-limit
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 1000, // limit each IP to 1000 requests per window
});

app.use('/graphql', limiter);

Complexity-Based Rate Limiting

Limit based on query cost, not just count:

// Track complexity per user
const userComplexityBudget = new Map();

const complexityPlugin = {
  requestDidStart() {
    return {
      didResolveOperation({ request, document, context }) {
        const complexity = calculateComplexity(document);
        const userId = context.user?.id || request.http?.headers.get('x-forwarded-for');

        const current = userComplexityBudget.get(userId) || 0;
        if (current + complexity > MAX_COMPLEXITY_PER_MINUTE) {
          throw new GraphQLError('Rate limit exceeded');
        }
        userComplexityBudget.set(userId, current + complexity);
      }
    };
  }
};

Field-Specific Rate Limiting

Limit expensive fields specifically:

type Query {
  """
  Rate limited to 10 requests per minute
  """
  expensiveAnalytics: Analytics! @rateLimit(max: 10, window: "1m")
}

Field-Level Authorization

Schema Design for Authorization

Don't expose unauthorized fields in schema:

# User-facing schema
type User {
  id: ID!
  name: String!
  publicProfile: PublicProfile!
}

# Admin-only schema (separate schema or schema stitching)
type User {
  id: ID!
  name: String!
  email: String!        # Admin only
  internalNotes: String # Admin only
}

Resolver-Level Authorization

Check permissions in resolvers:

const resolvers = {
  User: {
    email: (user, args, context) => {
      if (!context.user || context.user.id !== user.id) {
        if (!context.user?.isAdmin) {
          return null; // or throw error
        }
      }
      return user.email;
    },
    internalNotes: (user, args, context) => {
      if (!context.user?.isAdmin) {
        throw new GraphQLError('Not authorized', {
          extensions: { code: 'UNAUTHORIZED' }
        });
      }
      return user.internalNotes;
    }
  }
};

Directive-Based Authorization

directive @auth(requires: Role!) on FIELD_DEFINITION

enum Role {
  USER
  ADMIN
  SUPER_ADMIN
}

type User {
  id: ID!
  name: String!
  email: String! @auth(requires: USER)  # Own data or admin
  ssn: String @auth(requires: SUPER_ADMIN)
}

Input Validation

Schema-Level Validation

Use custom scalars for validation:

scalar Email      # Validates email format
scalar URL        # Validates URL format
scalar DateTime   # Validates ISO 8601 format

type Mutation {
  createUser(
    email: Email!
    website: URL
    birthDate: DateTime!
  ): User!
}

Input Constraints

Document and enforce constraints:

input CreatePostInput {
  """
  Title of the post.
  Min length: 1
  Max length: 200
  """
  title: String!

  """
  Post content.
  Max length: 50000
  """
  content: String!

  """
  Tags for the post.
  Max items: 10
  """
  tags: [String!]
}

Resolver Validation

Always validate in resolvers:

const resolvers = {
  Mutation: {
    createPost: (_, { input }) => {
      if (input.title.length > 200) {
        throw new GraphQLError('Title too long', {
          extensions: { code: 'VALIDATION_ERROR', field: 'title' }
        });
      }
      if (input.content.length > 50000) {
        throw new GraphQLError('Content too long', {
          extensions: { code: 'VALIDATION_ERROR', field: 'content' }
        });
      }
      if (input.tags?.length > 10) {
        throw new GraphQLError('Too many tags', {
          extensions: { code: 'VALIDATION_ERROR', field: 'tags' }
        });
      }
      // ... create post
    }
  }
};

Persisted Queries

What Are Persisted Queries?

Map query hashes to pre-approved queries:

Client sends: { "extensions": { "persistedQuery": { "sha256Hash": "abc123..." }}}
Server looks up: abc123... → "query GetUser($id: ID!) { user(id: $id) { id name }}"

Benefits

  1. Security: Only allow approved queries
  2. Performance: No parsing overhead
  3. Bandwidth: Smaller payloads
  4. CDN: Queries can be cached

Implementation

// Apollo Server with automatic persisted queries
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  persistedQueries: {
    cache: new KeyValueCache(), // Your cache implementation
  },
});

Strict Mode

In production, reject non-persisted queries:

const server = new ApolloServer({
  typeDefs,
  resolvers,
  persistedQueries: {
    cache: persistedQueryCache,
  },
  allowBatchedHttpRequests: false,
  plugins: [
    {
      async requestDidStart() {
        return {
          async didResolveOperation({ request }) {
            if (!request.extensions?.persistedQuery) {
              throw new GraphQLError('Only persisted queries allowed');
            }
          }
        };
      }
    }
  ]
});

Information Disclosure

Error Messages

Don't leak internal details:

// Bad: Exposes internal implementation
throw new Error(`Database error: SQLSTATE[23000]: duplicate key 'users_email_unique'`);

// Good: User-friendly message
throw new GraphQLError('Email already registered', {
  extensions: { code: 'EMAIL_EXISTS' }
});

Stack Traces

Disable in production:

const server = new ApolloServer({
  typeDefs,
  resolvers,
  includeStacktraceInErrorResponses: process.env.NODE_ENV === 'development',
});

Schema Descriptions

Don't expose sensitive information in descriptions:

# Bad: Exposes internal details
"""
User entity. Stored in PostgreSQL users table.
Synced with Salesforce via nightly cron job.
"""
type User { ... }

# Good: Public-facing description
"""
A user account in the system.
"""
type User { ... }

Field Nullability and Errors

Consider what null vs error reveals:

type User {
  # Returns null if no permission - reveals existence
  secretData: String

  # Returns error if no permission - might reveal existence
  secretData: String!
}

# Better: Same behavior whether exists or not
# Query returns null/error whether user exists or not

Source: SKILL.md on GitHub

No alerts1d5 checks · Risk SAFE
  • Gen Agent Trust Hub1d

    The skill is a comprehensive guide for GraphQL schema design, providing best practices for types, naming, pagination, error handling, and security. It is purely documentation and contains no executable code or malicious instructions.

  • Socket1d

    No alerts

  • Snyk1d

    Risk: LOW · No issues

  • Runlayer7mo

    1/6 files flagged

  • ZeroLeaks5mo

    1 finding · Score: 86/100

Signed by skilld at 79d2b07. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub yesterday.

Activeupdated 2 days ago
What it can do
Runs commands
compatibility
Any GraphQL implementation (Apollo Server, graphql-js, Yoga, etc.)
metadata
{
  "author": "apollographql",
  "version": "1.0.2"
}
All 1 allowed tools
Bash(npm:*) Bash(npx:*) Read Write Edit Glob Grep
  • Security
  • graphql
  • schema-design
  • api-design
  • apollo
  • nullability
  • pagination
  • mutations
  • type-design
  • backwards-compatibility

README badge

README badge for apollographql/skills/graphql-schema

Instructional skill covering GraphQL schema design best practices, including type definition patterns, nullability rules, pagination, error handling, and security considerations. Applicable to any GraphQL implementation (Apollo Server, graphql-js, Yoga) and targets decisions around type structures, backwards compatibility, and API usability.

Generated from the current SKILL.md.

Does this skill work with any GraphQL server library?
Yes. The skill is compatible with any GraphQL implementation including Apollo Server, graphql-js, Yoga, and others. It provides design guidance independent of server framework.
What topics does this skill cover?
The skill covers type design, nullability rules, input vs output types, interfaces, unions, pagination patterns, error modeling, security best practices, naming conventions, and mutation design.
Is this for existing schema review or new schema design?
Both. The skill applies when designing a new GraphQL schema, reviewing and improving existing schemas, deciding on type structures, or implementing specific patterns like pagination or error handling.
Does this include code generation or validation tools?
No. This skill is a design guide with reference documentation and best practices. It does not generate or validate schemas automatically.

Generated from the current SKILL.md. These answers refresh after source changes.