JustAppSec

API Design That Defends Itself

REST, GraphQL, and gRPC patterns that reduce your attack surface by design.

0:00

A well-designed API is inherently harder to misuse and easier to secure. This lesson covers security-first design principles for REST, GraphQL, and gRPC APIs — patterns that reduce your attack surface before you write a single security control.

Design principles

1. Minimal surface area

Expose only what is needed. Every endpoint, field, and parameter is attack surface.

  • Do not expose internal IDs if a public identifier (UUID, slug) works.
  • Do not return fields the client does not need. A GET /users/me endpoint returning password_hash, internal_notes, or mfa_secret is an information leak, even if the user "owns" the record.
  • Do not create endpoints speculatively. YAGNI applies to APIs too.

2. Explicit over implicit

Make access control decisions visible, not hidden in business logic:

# Implicit — authorisation buried in the query
def get_document(doc_id, user):
    return db.query("SELECT * FROM docs WHERE id = ? AND owner = ?", doc_id, user.id)

# Explicit — authorisation as a separate, testable step
def get_document(doc_id, user):
    doc = db.query("SELECT * FROM docs WHERE id = ?", doc_id)
    if not authorize(user, "read", doc):
        raise Forbidden()
    return doc

3. Fail closed

If something unexpected happens, deny access. Never default to "allow."

def authorize(user, action, resource):
    # Explicit grants only — no match means deny
    for rule in get_rules(user.role):
        if rule.matches(action, resource):
            return True
    return False  # Default: deny

REST API security patterns

Use resource-based URLs

GET  /orders/123        ← Good: resource-based
GET  /getOrder?id=123   ← Avoid: RPC-style
POST /deleteOrder       ← Avoid: side effects on POST

Resource-based URLs make it easier to apply consistent authorisation policies per resource.

Filter response fields

Return only the fields that the client needs:

// Bad — returns everything, including sensitive internal data
{
  "id": 123,
  "name": "Alice",
  "email": "[email protected]",
  "password_hash": "$2b$12$...",
  "internal_notes": "VIP customer",
  "created_by_admin": true
}

// Good — explicit response schema
{
  "id": 123,
  "name": "Alice",
  "email": "[email protected]"
}

Use response serialisation (DTOs, serializers, view models) to control what leaves the API. Never return raw database objects.

Pagination

Always paginate list endpoints. An unbounded GET /users that returns every record is a performance issue and a data exfiltration risk.

GET /users?page=1&per_page=50

Set a maximum per_page value server-side. Ignore client requests above that limit.

Rate limiting

Apply rate limits per endpoint, per user, and per IP. Sensitive endpoints (login, password reset, OTP verification) need stricter limits.

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1700000000

Versioning

Version your API so you can deprecate insecure patterns without breaking clients:

/api/v1/users  ← can be retired when v2 replaces it
/api/v2/users

Consistent error responses

Return the same error structure for every failure. Do not leak different information based on the type of error:

{
  "error": "not_found",
  "message": "Resource not found"
}

Specifically: do not return "user not found" vs. "incorrect password" on login. Return a generic "invalid credentials" for both. This prevents user enumeration.

GraphQL security

GraphQL has unique challenges because the client controls the query shape.

Disable introspection in production

Introspection lets anyone enumerate your entire schema — every type, field, and argument. Disable it in production:

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

Query depth and complexity limits

Without limits, a client can craft a deeply nested query that causes exponential database work:

{
  user {
    posts {
      comments {
        author {
          posts {
            comments {
              # ...20 levels deep
            }
          }
        }
      }
    }
  }
}

Use depth limiting and query cost analysis:

import depthLimit from "graphql-depth-limit";
import { createComplexityLimitRule } from "graphql-validation-complexity";

const server = new ApolloServer({
  validationRules: [
    depthLimit(5),
    createComplexityLimitRule(1000),
  ],
});

Per-field authorisation

Authorisation in GraphQL must be checked at the resolver level, not at the endpoint level. A single /graphql endpoint serves all queries — you cannot rely on URL-based middleware.

const resolvers = {
  User: {
    email: (parent, args, context) => {
      if (context.user.id !== parent.id && !context.user.isAdmin) {
        return null; // or throw
      }
      return parent.email;
    },
  },
};

Disable mutations for public APIs

If your GraphQL API is read-only for public users, do not include mutations in the public schema.

gRPC security

Use TLS

gRPC runs over HTTP/2, which supports TLS natively. Always enable TLS for gRPC connections, even between internal services.

Authenticate with metadata

gRPC uses metadata (similar to HTTP headers) for authentication. Pass tokens or certificates in metadata and validate them in interceptors:

func authInterceptor(ctx context.Context, req interface{},
    info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return nil, status.Error(codes.Unauthenticated, "missing metadata")
    }
    token := md.Get("authorization")
    if !validateToken(token) {
        return nil, status.Error(codes.Unauthenticated, "invalid token")
    }
    return handler(ctx, req)
}

Message size limits

Set maximum message sizes to prevent abuse:

grpc.MaxRecvMsgSize(4 * 1024 * 1024) // 4 MB

API documentation and security

Document your API's security model explicitly:

  • Which endpoints require authentication?
  • What roles or permissions are needed?
  • What rate limits apply?
  • What input constraints exist?

OpenAPI/Swagger supports security scheme documentation. Use it. When security requirements are documented, they are more likely to be tested and maintained.

Summary

Design APIs with minimal surface area, explicit access control, and fail-closed defaults. For REST, use resource-based URLs, filter response fields, paginate, and rate limit. For GraphQL, disable introspection, limit query depth and complexity, and enforce per-field authorisation. For gRPC, use TLS and authenticate via metadata. An API that is hard to misuse by design needs fewer security controls bolted on after the fact.


This training content is AI-assisted and reviewed by our team, but issues may be missed and best practices evolve rapidly. Send corrections to [email protected].