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/meendpoint returningpassword_hash,internal_notes, ormfa_secretis 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.
