API Design That Defends Itself

By Davy Rogers

REST, GraphQL, and gRPC patterns that shrink the attack surface by design.

The cheapest security control is a design decision you make before any code exists. An API that's shaped well from the start gives an attacker less to work with, and it does so without anyone having to remember a rule at three in the morning. Three principles drive most of the good decisions:

  • Keep the surface minimal. Every endpoint you expose is something to defend, so expose only what's needed.
  • Make access control explicit. It should be visible in the design, not buried implicitly in a query somewhere.
  • Fail closed. When something unexpected happens, deny.

REST patterns

A handful of habits cover most of the risk:

  • Resource-based URLs make it far easier to apply auth consistently across the API.
  • Filter the response fields with DTOs. Never serialise a raw database object straight to the client; you'll leak fields you forgot were there.
  • Paginate everything, always, with a server-side cap on page size. An unbounded list endpoint is a data-exfiltration endpoint.
  • Rate limit per endpoint and per user, harder on the auth routes than the rest.
  • Keep errors consistent. "Invalid credentials" leaks nothing; "user not found" versus "wrong password" tells an attacker which usernames are real.

GraphQL

GraphQL's flexibility is the thing you have to rein in, because the same query language that helps your clients helps an attacker probe and overload you.

Turn off introspection in production, and put limits on query depth and complexity so a deeply nested query can't trigger exponential database work:

const server = new ApolloServer({
  introspection: process.env.NODE_ENV !== "production",
  validationRules: [depthLimit(5), createComplexityLimitRule(1000)],
});

And because everything goes through a single /graphql endpoint, your authorisation has to live at the resolver level, on the individual field:

email: (parent, args, context) => {
  if (context.user.id !== parent.id && !context.user.isAdmin) return null;
  return parent.email;
}

gRPC

gRPC is often assumed safe because it's "internal," which is exactly the assumption attackers count on.

  • Use TLS always, even between internal services.
  • Authenticate via metadata, and validate it in an interceptor so the check is in one place rather than every handler.
  • Cap message sizes. grpc-go defaults to 4 MiB on receive; lower it for endpoints that only ever handle small payloads: grpc.MaxRecvMsgSize(1 * 1024 * 1024).

Whichever protocol you're using, the same three principles carry: a small surface, access control you can see, and a default of denial when something doesn't fit.