Web Services Architecture¶
How each major API paradigm works internally, why it was designed that way, and what trade-offs follow. The page covers REST, GraphQL, gRPC, SOAP, WebSocket, SSE, webhooks, tRPC, the HTTP transports underneath them, architectural patterns, and the API security threat model. Version numbers, status codes, and checklists live in Reference. Step-by-step tasks live in How-to Guides.
Protocol Comparison Overview¶
A production API platform rarely uses one paradigm. The diagram shows where each one typically sits on the request path, with real components at each layer.
flowchart LR
subgraph Clients
BR["Browser SPA"]
MOB["Mobile app"]
PART["Partner backend"]
end
subgraph Edge
CDN["CDN cache<br/>Cache-Control, ETag"]
GW["API gateway<br/>Kong / Envoy / AWS API Gateway"]
end
subgraph APIs["API layer"]
REST["REST + OpenAPI"]
ROUTER["GraphQL router<br/>Apollo Router / Cosmo"]
SSE["SSE / WebSocket<br/>stream server"]
end
subgraph Services["Internal services"]
SVC1["Orders service"]
SVC2["Users subgraph"]
SVC3["Inventory service"]
end
BR -->|"HTTPS JSON"| CDN
MOB -->|"HTTP/3 or HTTP/2"| CDN
CDN --> GW
PART -->|"REST, OAuth client credentials"| GW
GW --> REST
GW --> ROUTER
GW -->|"Upgrade / text/event-stream"| SSE
ROUTER -->|"GraphQL _entities"| SVC2
REST -->|"gRPC over HTTP/2"| SVC1
SVC1 -->|"gRPC"| SVC3
SVC1 -.->|"signed webhook POST"| PART
The decision flowchart below captures the usual first cut when you choose a paradigm for one API boundary.
flowchart TD
A["New API boundary"] --> B{"Who is the consumer?"}
B -->|"External developers, partners"| C{"Event notification<br/>or request/response?"}
C -->|"Request/response"| REST["REST + OpenAPI"]
C -->|"Events to their systems"| WH["Webhooks"]
B -->|"Your own frontends"| D{"Many clients, varied data shapes?"}
D -->|"Yes"| GQL["GraphQL (federated if multi-team)"]
D -->|"No, one TypeScript team"| TRPC["tRPC"]
D -->|"No, polyglot"| REST
B -->|"Internal services"| E{"Needs streaming or<br/>strict contracts?"}
E -->|"Yes"| GRPC["gRPC (or Connect for browser reach)"]
E -->|"No"| REST
B -->|"Real-time UI"| F{"Client sends often?"}
F -->|"Yes, bidirectional"| WS["WebSocket (WebTransport for datagrams)"]
F -->|"No, server push only"| SSEN["SSE"]
B -->|"Legacy enterprise system"| SOAP["SOAP / WSDL"]
The side-by-side comparison table (transport, format, direction, browser support) is in Reference — Protocol Comparison.
REST (Representational State Transfer)¶
Roy Fielding defined REST in his 2000 doctoral dissertation as an architectural style, not a protocol. REST is built on six constraints. Together they produce a scalable, stateless, and cacheable web service.
The Six Architectural Constraints¶
1. Client–Server Separation¶
The client and server evolve independently. The server manages data storage and business logic. The client manages the user interface and user state. Neither side depends on the implementation details of the other — only on the shared API contract.
This decoupling lets frontend teams swap frameworks (React → Vue) and lets mobile clients evolve. No backend change is required, and the reverse holds too.
2. Stateless¶
Every request from client to server must contain all information necessary to understand and process the request. The server stores no session state between requests.
Stateful (server stores session):
POST /login → server creates session, returns cookie
GET /dashboard → server reads session to identify user
Stateless (client carries state):
GET /dashboard
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
Consequences: - Scalability: any server instance can handle any request — no sticky sessions - Reliability: no session state to lose if a server crashes - Overhead: every request must carry auth credentials and context (larger payloads)
3. Cacheable¶
Responses must declare whether they are cacheable or not. When responses are cacheable, clients and intermediaries (CDNs, proxies) can serve them without contacting the server.
Key HTTP cache headers:
| Header | Purpose | Example |
|---|---|---|
| Cache-Control | Directives for caching behavior | Cache-Control: max-age=3600, public |
| ETag | Fingerprint of resource version | ETag: "d8e8fca2dc0f896fd7cb4cb0031ba249" |
| Last-Modified | When resource last changed | Last-Modified: Tue, 22 Apr 2026 12:00:00 GMT |
| Vary | Which headers affect the cache key | Vary: Accept-Encoding, Authorization |
Conditional requests let clients validate their cache:
GET /users/42
If-None-Match: "d8e8fca2dc0f896fd7cb4cb0031ba249"
→ 304 Not Modified (body omitted — client uses cached copy)
→ 200 OK + new ETag + new body (cache miss — resource changed)
4. Uniform Interface¶
This is the single most important constraint. It defines four sub-principles:
4a. Resource Identification in Requests — every resource has a stable URI:
/users → collection of users
/users/42 → specific user
/users/42/orders → orders belonging to user 42
/users/42/orders/7/items → items in that order
4b. Manipulation via Representations — clients hold representations (JSON, XML, HTML), not live objects. The client modifies the representation and sends it back.
4c. Self-Descriptive Messages — each request/response carries enough metadata to describe how to process it: Content-Type, method, status code, cache directives.
4d. HATEOAS — see section below.
5. Layered System¶
Clients cannot tell whether they are connected directly to the server or to an intermediary (load balancer, CDN, API gateway, caching proxy). Each layer only knows about the adjacent layer.
You can put these layers in transparently: - CDNs for caching at the edge - API gateways for auth, rate limiting, routing - Load balancers for distributing traffic - Service meshes for observability and mTLS
6. Code on Demand (optional)¶
This is the only optional constraint. Servers can temporarily extend client function by transferring executable code (for example, JavaScript). It is rarely relevant in modern API design.
Methods, Safety, and Idempotency¶
REST leans on HTTP method semantics instead of inventing verbs. Safe methods (GET, HEAD, OPTIONS, and the new QUERY method from RFC 10008) promise no state change. Idempotent methods (the safe ones plus PUT and DELETE) promise that N identical requests have the same effect as one. These two properties are what let browsers, proxies, and client libraries retry, prefetch, and cache without asking the application. POST and PATCH carry neither promise, which is why retry-safe POST needs an explicit Idempotency-Key (see How-to Guides).
Status codes play the same role for responses: a generic intermediary can act on 304, 429, or 503 + Retry-After without understanding the payload. The full method table and the status code catalog (with RFC 9110 names) are in Reference — HTTP Methods and Idempotency and Reference — HTTP Status Codes.
PATCH Semantics: JSON Patch vs JSON Merge Patch¶
PATCH is the most nuanced HTTP method. The two dominant formats behave very differently:
JSON Merge Patch (RFC 7396) — simple and intuitive. Send only the fields you want to change:
PATCH /users/42 HTTP/1.1
Content-Type: application/merge-patch+json
{"email": "new@example.com", "phone": null}
The server merges the patch with the existing resource. email is updated, phone is removed (explicit null), and all other fields stay unchanged.
Limitation: you cannot set a field to null and leave it present — null always means "remove." This makes JSON Merge Patch unusable for APIs where null is a meaningful value.
JSON Patch (RFC 6902) — explicit operations array, more powerful but more complex:
PATCH /users/42 HTTP/1.1
Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "/email", "value": "new@example.com" },
{ "op": "remove", "path": "/phone" },
{ "op": "add", "path": "/addresses/1", "value": {"city": "Berlin"} },
{ "op": "test", "path": "/version", "value": 3 }
]
Operations: add, remove, replace, move, copy, test. The test operation enables optimistic concurrency — the patch fails atomically if the tested value does not match.
| Dimension | JSON Merge Patch | JSON Patch |
|---|---|---|
| RFC | 7396 | 6902 |
| Content-Type | application/merge-patch+json |
application/json-patch+json |
| Format | Partial JSON object | Array of operations |
| Set field to null | No (null = remove) | Yes: {"op": "replace", "path": "/x", "value": null} |
| Array operations | Replace entire array only | Add/remove individual elements |
| Atomicity | No built-in check | test operation for optimistic locking |
| Complexity | Low — just send partial object | Higher — must construct operation array |
| Adoption | More common (partial-JSON PATCH bodies such as the GitHub REST API; Kubernetes accepts both) | Less common. Used when precision is needed |
Practical Recommendation
If you need array element manipulation, optimistic concurrency via test, or a way to distinguish "set to null" from "remove", use JSON Patch. Most APIs use JSON Merge Patch for simplicity.
HATEOAS¶
Hypermedia as the Engine of Application State — the highest constraint of REST. Responses include hyperlinks that describe the actions available next. Clients need no prior knowledge of URL structure. They navigate by following links.
{
"id": 42,
"name": "Alice",
"email": "alice@example.com",
"_links": {
"self": { "href": "/users/42", "method": "GET" },
"orders": { "href": "/users/42/orders", "method": "GET" },
"update": { "href": "/users/42", "method": "PUT" },
"delete": { "href": "/users/42", "method": "DELETE" }
}
}
Benefits: the API is self-documenting, the server can change URL structure without breaking clients, and workflow steps are discoverable.
In practice: very few production APIs implement full HATEOAS. Most APIs get to Level 2 of the Richardson Maturity Model (proper HTTP verbs) and stop there.
Richardson Maturity Model¶
A framework for measuring how RESTful an API is:
| Level | Name | What It Adds | Example |
|---|---|---|---|
| 0 | Swamp of POX | Single endpoint, single method | POST /api with XML body specifying action |
| 1 | Resources | Multiple URIs, but still single HTTP verb | POST /users, POST /users/42 |
| 2 | HTTP Verbs | Uses GET/POST/PUT/DELETE meaningfully | GET /users/42, DELETE /users/42 |
| 3 | Hypermedia | Responses contain links for navigation (HATEOAS) | JSON with _links section |
Roy Fielding stated that Level 3 is the pre-condition of REST. Most production APIs sit at Level 2. That level is fine for practical purposes, even if it is technically not "truly RESTful."
GraphQL¶
Facebook created GraphQL in 2012 and open-sourced it in 2015. Since 2019 the GraphQL Foundation (a Linux Foundation project) governs the specification. The current edition is September 2025, the first since October 2021. It adds OneOf input objects (@oneOf), schema coordinates, and descriptions on executable documents. GraphQL is a query language for your API and a runtime for executing those queries. Clients ask for exactly the data they need and nothing more.
Core Concept: Single Endpoint¶
Unlike the resource-per-endpoint model of REST, GraphQL exposes a single endpoint (typically POST /graphql) that accepts queries describing the exact shape of data needed.
# REST requires 3 round trips:
# GET /users/42
# GET /users/42/posts
# GET /posts/7/comments
# GraphQL fetches all in one request:
query {
user(id: 42) {
name
email
posts(limit: 5) {
title
publishedAt
comments(limit: 3) {
body
author { name }
}
}
}
}
Type System and Schema¶
Everything in GraphQL is strongly typed. The schema is the single source of truth — it describes every piece of data the API can return and every operation clients can do.
Scalar Types¶
Built-in primitives: Int, Float, String, Boolean, ID. You can define custom scalars (for example, DateTime, URL, JSON).
Object Types¶
type User {
id: ID! # ! = non-nullable
name: String!
email: String!
createdAt: DateTime!
posts: [Post!]! # non-null list of non-null Posts
}
type Post {
id: ID!
title: String!
body: String
author: User!
tags: [String!]!
}
Special Root Types¶
type Query {
user(id: ID!): User
users(limit: Int = 20, offset: Int = 0): [User!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
type Subscription {
userCreated: User!
messageReceived(roomId: ID!): Message!
}
Other Type Categories¶
| Type | Purpose | Example |
|---|---|---|
| Input | Arguments to mutations | input CreateUserInput { name: String!, email: String! } |
| Enum | Fixed set of values | enum Status { ACTIVE INACTIVE SUSPENDED } |
| Interface | Shared fields across types | interface Node { id: ID! } |
| Union | Type can be one of many | union SearchResult = User \| Post \| Comment |
| Fragment | Reusable field selection | fragment UserFields on User { id name email } |
| OneOf input (Sept 2025 spec) | Input where exactly one field must be set | input UserBy @oneOf { id: ID, email: String } |
Queries, Mutations, Subscriptions¶
Query — read data. Resolvers can be called in parallel:
query GetDashboard {
currentUser {
name
notifications(unread: true) { id title }
}
trending { title views }
}
Mutation — write data. Resolvers execute sequentially:
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
author { name }
}
}
Subscription — real-time data pushed by the server when events occur. The common transports are WebSocket with the graphql-ws protocol (the older subscriptions-transport-ws protocol is deprecated) SSE (graphql-sse, supported by GraphQL Yoga), and the HTTP multipart subscription protocol that Apollo Router uses toward clients:
subscription OnMessageReceived($roomId: ID!) {
messageReceived(roomId: $roomId) {
id body sender { name } sentAt
}
}
Resolvers¶
Resolvers are functions that produce data for each field in the schema. GraphQL execution is a depth-first traversal of the query tree — each field resolver receives:
parent— resolved value of the parent fieldargs— arguments passed to this fieldcontext— shared object (DB connection, auth user, DataLoaders)info— query metadata (field name, selection set, schema)
const resolvers = {
Query: {
user: async (_, { id }, { db }) => db.users.findById(id),
users: async (_, { limit, offset }, { db }) =>
db.users.findAll({ limit, offset }),
},
User: {
// Parent resolver returned a user object; now resolve its posts field
posts: async (user, { limit }, { db }) =>
db.posts.findByUserId(user.id, limit),
},
Mutation: {
createUser: async (_, { input }, { db }) => db.users.create(input),
},
};
The N+1 Problem¶
This is the most common GraphQL performance trap. Without optimization, resolving a list of N users and their posts triggers 1 + N queries:
Query: users(limit: 20) → SELECT * FROM users LIMIT 20 (1 query)
User[0].posts → SELECT * FROM posts WHERE user_id = 1 (1 query)
User[1].posts → SELECT * FROM posts WHERE user_id = 2 (1 query)
...
User[19].posts → SELECT * FROM posts WHERE user_id = 20 (1 query)
TOTAL: 21 queries
The impact compounds with nesting. Posts that fetch authors that fetch their posts can generate hundreds of queries for a single GraphQL request.
DataLoader — The Solution¶
DataLoader (created at Facebook, now maintained under the GraphQL Foundation) batches and caches loads within a single request. It collects all load() calls made in the same tick of the Node.js event loop into one batch:
import DataLoader from 'dataloader';
// Created once per request (NOT per application startup)
const postsByUserLoader = new DataLoader(async (userIds: readonly string[]) => {
// Single batch query: SELECT * FROM posts WHERE user_id IN (1, 2, ..., 20)
const posts = await db.posts.findByUserIds(userIds);
// Return results in same order as input keys
return userIds.map(id => posts.filter(p => p.userId === id));
});
// In resolver — these 20 calls become ONE SQL query
const resolvers = {
User: {
posts: (user, _, { loaders }) =>
loaders.postsByUser.load(user.id), // batched automatically
},
};
Result: 21 queries → 2 queries (one for users, one batch for all posts).
DataLoader Instance Per Request
Create a new DataLoader instance for each request. DataLoader caches results for the duration of a request — sharing across requests will serve stale data.
Directives¶
Directives annotate schema elements or control query execution:
type User {
email: String! @deprecated(reason: "Use contactEmail instead")
contactEmail: String!
password: String! @auth(requires: ADMIN) # custom directive
}
# Built-in execution directives:
query GetUser($showEmail: Boolean!, $skipPhone: Boolean!) {
user(id: 42) {
name
email @include(if: $showEmail) # conditionally include field
phone @skip(if: $skipPhone) # conditionally skip field
}
}
Introspection¶
GraphQL APIs are self-documenting — clients can query the schema itself:
Introspection powers tools like GraphiQL, Apollo Studio, and GraphQL Playground. Disable introspection in production for security-sensitive APIs.
Query Complexity and Depth Limiting¶
Without limits, a malicious client can craft exponentially expensive queries:
# Denial-of-service via deeply nested query:
{ user { friends { friends { friends { friends { ... } } } } } }
Protect with:
- Depth limiting: reject queries deeper than N levels (graphql-depth-limit)
- Complexity analysis: assign costs to fields. Reject queries over a budget (graphql-validation-complexity)
- Query whitelisting (persisted queries): only allow pre-approved queries in production
Federation¶
GraphQL Federation lets multiple teams own separate subgraphs that compose into a unified supergraph — one schema, one endpoint, distributed implementation. Composition happens at build time (Rover CLI or GraphOS schema checks produce a supergraph schema). At request time the router builds a query plan, calls each subgraph, and stitches the entity data together through the _entities field.
The sequence shows one query that spans two subgraphs:
sequenceDiagram
participant C as Client
participant R as Apollo Router (supergraph)
participant U as Users subgraph
participant O as Orders subgraph
C->>R: POST /graphql query { me { name orders { total } } }
R->>R: Validate against supergraph schema, build query plan
R->>U: query { me { __typename id name } }
U-->>R: { me: { __typename: User, id: 42, name: Alice } }
R->>O: query($r: [_Any!]!) { _entities(representations: $r) { ... on User { orders { total } } } }
O->>O: __resolveReference for User id 42
O-->>R: { _entities: [ { orders: [ { total: 99 } ] } ] }
R-->>C: Merged response { data: { me: { name, orders } } }
Key concepts:
- Entities: types that can be extended across subgraphs, identified by the
@keydirective (for exampletype User @key(fields: "id")). __resolveReference: resolver that hydrates an entity from the key representation the router passes to_entities.@external,@requires,@provides: declare fields owned by another subgraph and how to fetch them.@shareable,@override,@inaccessible: Federation 2 directives for shared fields, progressive field migration, and hiding fields from the public API.- Each subgraph deploys independently. Schema checks catch composition errors before deploy.
Routers and licensing
Apollo Router (GraphOS Router, 2.x, Rust) and Apollo Federation 2 composition are source-available under the Elastic License 2.0. Apache-2.0 alternatives that speak the Apollo Federation protocol include WunderGraph Cosmo Router and The Guild's Hive Gateway. An open, vendor-neutral federation spec is in progress at the GraphQL Foundation (Composite Schemas working group), but it is still at Stage 0: Preliminary (2026-09).
Federation vs Schema Stitching¶
Before Federation, schema stitching was the primary approach to composing multiple GraphQL services. They solve the same problem differently:
| Dimension | Schema Stitching | Federation |
|---|---|---|
| Composition | Gateway merges schemas at runtime | Router composes via a supergraph schema |
| Type ownership | Gateway defines cross-service types | Each subgraph owns its types via @key |
| Coupling | Gateway knows about the internal types of all subgraphs | Subgraphs are self-contained. The router only knows entities |
| Deployment | Change in one subgraph can require gateway redeploy | Subgraphs deploy independently |
| Conflict resolution | Manual: gateway resolves field name conflicts | Automatic: @override, @provides, @shareable directives |
| Tooling | GraphQL Tools (@graphql-tools/stitch), Hive Gateway |
Apollo Router + GraphOS, Cosmo Router, Hive Gateway |
| Status | Still maintained (The Guild). Less common for new multi-team graphs | De facto standard for multi-team GraphQL |
Stitching still makes sense for: small teams, legacy services in gradual migration, and third-party GraphQL APIs that you do not control (federation requires subgraphs to add @key directives).
Error Handling¶
GraphQL errors behave fundamentally differently from REST:
Partial responses — in REST, an error means the entire response fails. In GraphQL, individual fields can fail while the rest of the response succeeds:
{
"data": {
"user": {
"name": "Alice",
"email": "alice@example.com",
"creditScore": null
}
},
"errors": [
{
"message": "Unauthorized to access creditScore",
"locations": [{ "line": 5, "column": 5 }],
"path": ["user", "creditScore"],
"extensions": {
"code": "UNAUTHORIZED",
"classification": "AUTHORIZATION"
}
}
]
}
The data field contains whatever succeeded. The errors field contains what failed. The client must handle both.
Error extensions — the extensions field is the standard way to add machine-readable error metadata:
// Apollo Server — throw typed error with extensions
import { GraphQLError } from 'graphql';
throw new GraphQLError('Order not found', {
extensions: {
code: 'NOT_FOUND',
http: { status: 404 },
orderId: input.id,
traceId: ctx.traceId,
},
});
Error masking — in production, mask internal errors to prevent leaking implementation details:
// Apollo Server 5 (same API as v4, which reached end-of-life on 2026-01-26) — format error for production
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// Log full error internally
logger.error(error);
// Return sanitized error to client
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return { message: 'Internal server error', extensions: { code: 'INTERNAL_SERVER_ERROR' } };
}
return formattedError;
},
});
Error classification patterns:
| Code | Meaning | HTTP Equivalent |
|---|---|---|
BAD_USER_INPUT |
Invalid query variables | 400 |
UNAUTHENTICATED |
Missing or invalid auth | 401 |
FORBIDDEN |
Authenticated but not authorized | 403 |
NOT_FOUND |
Resource does not exist | 404 |
GRAPHQL_VALIDATION_FAILED |
Query does not match schema | 400 |
PERSISTED_QUERY_NOT_FOUND |
Unknown query hash (APQ miss) | 400 |
INTERNAL_SERVER_ERROR |
Unhandled server error | 500 |
Caching¶
GraphQL caching is fundamentally harder than REST caching. Requests use POST with dynamic query bodies, so HTTP caches cannot distinguish between different queries to the same /graphql endpoint.
HTTP-level caching (limited):
- GET requests for queries: GET /graphql?query={user(id:42){name}}&variables={} — cacheable by CDN, but URL length limits apply
- Automatic Persisted Queries (APQ) solve this: GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc..."}}&variables={"id":"42"} — short, cacheable, CDN-friendly
Client-side normalized caching (Apollo Client):
Apollo Client maintains an in-memory normalized cache keyed by __typename:id:
Cache store:
User:42 → { __typename: "User", id: "42", name: "Alice", email: "alice@example.com" }
Post:7 → { __typename: "Post", id: "7", title: "Hello", author: { __ref: "User:42" } }
Post:8 → { __typename: "Post", id: "8", title: "World", author: { __ref: "User:42" } }
When a mutation updates User:42, every query displaying that user re-renders automatically — no manual cache invalidation. This is the primary DX advantage of GraphQL over REST for complex frontends.
Cache policies:
| Policy | Behavior | Use Case |
|---|---|---|
cache-first |
Read from cache. Network only on miss | Default. Best for mostly-static data |
network-only |
Always fetch. Update cache | Dashboards, real-time displays |
cache-and-network |
Return cache immediately, then update with network | Instant UI + fresh data |
no-cache |
Fetch without reading or updating cache | One-off queries, sensitive data |
Server-side caching:
- Response-level: cache full GraphQL responses keyed by query hash + variables (Redis)
- Resolver-level: cache individual resolver results (DataLoader already provides per-request caching. Add Redis for cross-request caching)
- @cacheControl directive (Apollo): per-field cache hints
type Product @cacheControl(maxAge: 3600) {
id: ID!
name: String!
price: Float! @cacheControl(maxAge: 60) # price changes more often
reviews: [Review!]! @cacheControl(maxAge: 300)
}
gRPC¶
gRPC ("gRPC Remote Procedure Calls", open-sourced by Google in 2015) is a high-performance, open-source RPC framework. It uses Protocol Buffers as its interface definition language and serialization format and HTTP/2 as the transport. It has been a CNCF incubating project since February 2017. Current releases and wire-level defaults are in Reference — gRPC Wire Protocol and Defaults.
Protocol Buffers (Protobuf)¶
Protobuf is a language-neutral, platform-neutral binary serialization format. Compared to JSON:
| Property | JSON | Protobuf |
|---|---|---|
| Format | Text (UTF-8) | Binary |
| Size | 1x baseline | Typically several times smaller (payload-dependent) |
| Parse speed | 1x baseline | Typically several times faster (language- and library-dependent) |
| Schema | Optional (JSON Schema) | Required (.proto file) |
| Human-readable | Yes | No (need tools) |
| Schema evolution | Manual / fragile | Built-in field numbering |
A .proto service definition:
syntax = "proto3";
package com.example.users;
// Message types
message User {
string id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
}
message GetUserRequest { string user_id = 1; }
message CreateUserRequest {
string name = 1;
string email = 2;
}
message UserList { repeated User users = 1; }
// Service definition
service UserService {
// Unary
rpc GetUser(GetUserRequest) returns (User);
// Server streaming
rpc ListUsers(ListUsersRequest) returns (stream User);
// Client streaming
rpc CreateUsersBulk(stream CreateUserRequest) returns (UserList);
// Bidirectional streaming
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
The protoc compiler (or buf generate) generates strongly-typed client stubs and server interfaces in Go, Java, Python, C++, Node.js, Rust, Kotlin, Swift, and more.
Protobuf Editions
New .proto files can use edition = "2024"; instead of syntax = "proto3";. Editions replace the proto2/proto3 split with per-feature defaults that can be overridden per file, message, or field. Edition 2024 is the latest released edition (protobuf.dev, 2026-09). The wire format is unchanged, so proto3 and edition files interoperate.
HTTP/2 Features Exploited by gRPC¶
| HTTP/2 Feature | What It Enables |
|---|---|
| Multiplexing | Multiple RPC calls on one TCP connection. No head-of-line blocking between requests |
| Binary framing | Headers and data sent as binary frames — more efficient than HTTP/1.1 text headers |
| Header compression (HPACK) | Repeated headers (auth token, content-type) sent as index references after first use, which removes most repeated header bytes on long-lived connections |
| Full-duplex streams | Client and server can send frames simultaneously on the same stream |
| Flow control | Prevents fast producers from overwhelming slow consumers per-stream |
| Trailers | gRPC sends grpc-status and grpc-message as HTTP/2 trailers after the body. Browsers cannot read trailers from fetch, which is why gRPC-Web exists |
The Four Streaming Types¶
Unary RPC¶
This is the classic request-response pattern. The client sends one message, and the server sends one message. It is equivalent to a RESTGET.
Server Streaming RPC¶
The client sends one request. The server streams multiple responses. Use cases: live logs, large dataset export, real-time feeds.Client Streaming RPC¶
The client streams multiple messages. The server collects them and returns one response. Use cases: telemetry ingestion, file uploads chunked by the client, batch writes.Bidirectional Streaming RPC¶
Both sides can send and receive messages in any order over a long-lived connection. The two streams operate independently. Use cases: chat, collaborative editing, real-time games, audio/video signaling.Deadlines and Cancellation¶
Every gRPC call should set a deadline — the absolute time by which the client requires a response. gRPC has no default deadline, so a call without one can hang forever. The deadline travels to the server in the grpc-timeout header, and the server should check it before starting expensive work.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: "42"})
Deadlines propagate through the entire call chain. If service A calls service B, and service B calls service C, all three respect the same deadline window. One slow downstream call then cannot cause timeouts at every layer.
Interceptors¶
Interceptors wrap gRPC method invocations — the gRPC equivalent of middleware:
// Unary server interceptor for logging
func loggingInterceptor(ctx context.Context, req interface{},
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler,
) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req)
log.Printf("Method: %s | Duration: %v | Error: %v",
info.FullMethod, time.Since(start), err)
return resp, err
}
// Register:
s := grpc.NewServer(
grpc.UnaryInterceptor(loggingInterceptor),
grpc.StreamInterceptor(streamLoggingInterceptor),
)
Common interceptors: authentication, tracing (OpenTelemetry), logging, metrics, panic recovery, rate limiting, deadline enforcement.
Load Balancing¶
Because gRPC multiplexes many RPCs over a single TCP connection, L4 (TCP) load balancing distributes connections, not RPCs. A single long-lived connection from service A to a single pod of service B bypasses all other pods.
Solutions:
- L7 (application-layer) load balancing — a proxy that understands HTTP/2 streams distributes individual RPCs: Envoy, nginx, service-mesh sidecars (Istio, Linkerd)
- Client-side load balancing — the gRPC client resolves all backend IPs (via DNS), keeps connections to each, and distributes RPCs itself (round_robin policy)
- Headless services in Kubernetes — DNS returns all pod IPs. Combine with gRPC client-side round-robin
- Proxyless xDS — gRPC clients read endpoints and policies from an xDS control plane (for example, Istio or Traffic Director) without a sidecar
gRPC-Web (Browser Bridge)¶
Browsers cannot make native HTTP/2 gRPC calls (no access to HTTP/2 frames or trailers). gRPC-Web bridges this gap with a protocol translation proxy.
flowchart LR
B["Browser<br/>gRPC-Web client"] -->|"HTTP/1.1 or HTTP/2<br/>Content-Type: application/grpc-web"| P["Envoy proxy<br/>envoy.filters.http.grpc_web"]
P -->|"Native HTTP/2 gRPC"| S["gRPC server"]
How it works:
1. Browser client uses grpc-web (npm) or @connectrpc/connect-web to make gRPC calls
2. Calls are encoded as application/grpc-web (base64 or binary) over standard HTTP
3. Envoy proxy (or Connect protocol server) translates to native gRPC
4. Server sees standard gRPC requests — no code changes needed
// Browser client using Connect (modern alternative to grpc-web)
import { createClient } from "@connectrpc/connect";
import { createGrpcWebTransport } from "@connectrpc/connect-web";
import { UserService } from "./gen/users_pb"; // Connect v2 + protobuf-es v2 generate services into *_pb files
const transport = createGrpcWebTransport({
baseUrl: "https://api.example.com",
});
const client = createClient(UserService, transport);
const user = await client.getUser({ userId: "42" });
gRPC-Web limitations: - Only unary and server-streaming RPCs (no client-streaming or bidirectional) - Requires a proxy (Envoy, Connect, nginx) unless using Connect protocol natively - Slightly higher latency due to protocol translation
Connect RPC (created by Buf, a CNCF sandbox project since April 2024) is the modern alternative. Connect servers speak gRPC, gRPC-Web, and the Connect protocol on the same HTTP endpoint. The Connect protocol works over HTTP/1.1 or HTTP/2, supports plain JSON, and lets idempotent RPCs use cacheable GET requests, so browsers can call it without a translation proxy.
SOAP / XML-RPC¶
SOAP (Simple Object Access Protocol) is the predecessor to REST. It is still deeply embedded in enterprise systems, financial services, healthcare (HL7), and government integrations.
Protocol Structure¶
A SOAP message is an XML document with a mandatory Envelope, optional Header, and mandatory Body:
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:usr="http://example.com/users">
<soap:Header>
<usr:AuthToken>abc123</usr:AuthToken>
</soap:Header>
<soap:Body>
<usr:GetUser>
<usr:UserId>42</usr:UserId>
</usr:GetUser>
</soap:Body>
</soap:Envelope>
WSDL (Web Services Description Language)¶
WSDL is the SOAP IDL — an XML document that describes the service completely. It covers operations, input/output message types, bindings (how operations map to protocols), and endpoints. It serves the same role as OpenAPI for REST or .proto files for gRPC.
<wsdl:definitions name="UserService" ...>
<wsdl:types>
<xs:schema>
<xs:element name="GetUserRequest">
<xs:complexType>
<xs:sequence>
<xs:element name="UserId" type="xs:string"/>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
</wsdl:types>
<wsdl:message name="GetUserInput">
<wsdl:part name="parameters" element="tns:GetUserRequest"/>
</wsdl:message>
<wsdl:portType name="UserServicePortType">
<wsdl:operation name="GetUser">
<wsdl:input message="tns:GetUserInput"/>
<wsdl:output message="tns:GetUserOutput"/>
</wsdl:operation>
</wsdl:portType>
</wsdl:definitions>
SOAP vs REST¶
| Dimension | SOAP | REST |
|---|---|---|
| Payload | XML (verbose) | JSON (compact) |
| Contract | WSDL (machine-readable) | OpenAPI (optional) |
| Transport | HTTP, SMTP, TCP | HTTP only |
| State | Stateful or stateless | Stateless |
| Security | WS-Security (powerful but complex) | OAuth 2.0, JWT, mTLS |
| Error handling | soap:Fault (standardized) |
HTTP status codes (convention-based) |
| Tooling | Mature but heavy | Light and universal |
| Still used for | Banking, insurance, health (HL7), government | Virtually everything new |
XML-RPC predates SOAP. It is a simpler, less extensible ancestor that uses XML payloads over HTTP POST. It is effectively obsolete.
WebSocket¶
WebSocket (RFC 6455, 2011) provides a persistent, full-duplex message channel between client and server. Classic WebSocket is bootstrapped from an HTTP/1.1 Upgrade handshake and then owns the TCP connection. RFC 8441 (HTTP/2) and RFC 9220 (HTTP/3) let a WebSocket run as one stream of a multiplexed connection via extended CONNECT. Once established, either side can send messages at any time with a 2–14 byte frame header.
Handshake¶
# Client initiates upgrade:
GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
# Server confirms upgrade:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The server proves it speaks WebSocket by returning Sec-WebSocket-Accept = base64(SHA-1(Sec-WebSocket-Key + fixed GUID)). After the handshake, the connection is no longer HTTP. Data flows as frames (opcodes and close codes are in Reference):
| Frame Type | Description |
|---|---|
| Text frame | UTF-8 text message |
| Binary frame | Raw bytes (audio, video, protobuf) |
| Ping frame | Heartbeat probe (either endpoint may send) |
| Pong frame | Heartbeat response (browsers answer pings automatically; the browser WebSocket API cannot send pings) |
| Close frame | Graceful connection termination |
Connection Management¶
The primary operational challenge of WebSocket is connection state management:
- Heartbeats (ping/pong): they detect dead connections that appear open at the TCP layer and keep NAT and load-balancer idle timers alive. A common practice is a server ping every 30–60 seconds. If no pong arrives, close the connection and clean up.
- Reconnection: clients must implement exponential backoff for connection drops. Libraries like
reconnecting-websockethandle this automatically. - Backpressure: if a slow client cannot consume fast enough, the send buffer of the server fills. Monitor
ws.bufferedAmounton the client, or implement application-level flow control. - Horizontal scaling: WebSocket connections are stateful and sticky. A message sent by user A (connected to server 1) destined for user B (connected to server 2) must be routed between servers via a pub/sub layer (Redis Pub/Sub, Kafka).
When to Use WebSocket¶
- Interactive real-time features: chat, collaborative document editing, multiplayer gaming
- Financial data: live order books, tick-by-tick price feeds
- IoT: bidirectional device control with low latency
- When the client sends frequent data to the server (>1 msg/second)
WebTransport
WebTransport runs over HTTP/3 (QUIC) and gives the browser multiple independent streams plus unreliable datagrams, which removes WebSocket's single-stream head-of-line blocking. The browser API ships in Chrome 97+, Firefox 114+, and Safari 26.4+ (MDN browser-compat-data, 2026-09), but the IETF protocol (draft-ietf-webtrans-http3) is still an Internet-Draft. Use it for media, games, and telemetry that tolerate loss. Keep WebSocket as the fallback.
Server-Sent Events (SSE)¶
SSE is defined in the WHATWG HTML Living Standard (the EventSource interface; it is not an IETF RFC). It streams from server to client over plain HTTP. Unlike WebSocket, there is no protocol upgrade. An SSE stream is a long-lived HTTP response with Content-Type: text/event-stream.
Protocol¶
Server response:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
id: 1
event: message
data: {"type": "notification", "text": "Hello!"}
id: 2
event: update
data: {"user": "alice", "status": "online"}
: heartbeat comment (ignored by client)
SSE message fields:
| Field | Purpose |
|---|---|
| data: | The message payload (can span multiple lines) |
| event: | Custom event type (client listens via addEventListener) |
| id: | Message ID. Sent as Last-Event-ID header on reconnect |
| retry: | Reconnection delay in milliseconds |
| : (comment) | Ignored by client. Used for keepalive pings |
Auto-Reconnection¶
The key SSE feature: if the connection drops, the browser automatically reconnects and sends the Last-Event-ID header. The server can resume from where it stopped. No client code is required.
const source = new EventSource('/events');
source.addEventListener('message', e => console.log(e.data));
source.addEventListener('update', e => handleUpdate(JSON.parse(e.data)));
source.onerror = e => console.error('SSE error', e);
// Reconnection happens automatically — no manual retry logic needed
HTTP/2 SSE¶
Under HTTP/1.1, browsers limit each origin to about 6 concurrent connections, and every open EventSource holds one. With several tabs open, SSE connections starve XHR/fetch requests. Under HTTP/2 or HTTP/3, SSE streams multiplex over one connection, so the per-origin connection limit stops being the constraint (the server's concurrent-stream limit, often 100, applies instead).
AI Streaming
SSE is the de facto transport for LLM token streaming. The OpenAI and Anthropic APIs, among most others, stream completions as text/event-stream. Data flows in one direction (server to client) and SSE is simpler than WebSocket. Note that these APIs are called with POST via fetch() and a stream parser, not EventSource (which only supports GET), so browser auto-reconnect does not apply to them.
Webhooks¶
Webhooks are HTTP POST callbacks — the server pushes events to client-registered URLs instead of the client polling for changes. "Do not call us, we will call you."
Flow¶
sequenceDiagram
participant Client
participant YourServer
participant WebhookConsumer
Client->>YourServer: Register webhook URL
Note over YourServer: Event occurs (payment, commit, signup)
YourServer->>WebhookConsumer: POST /webhook {"event": "payment.succeeded", ...}
WebhookConsumer-->>YourServer: 200 OK (within 5s)
Note over WebhookConsumer: Queue event for async processing
Production Webhook Pattern¶
Respond immediately, process asynchronously:
@app.post("/webhook")
async def webhook_handler(request: Request, background_tasks: BackgroundTasks):
body = await request.body() # raw bytes: the signature covers these exact bytes
# 1. Validate signature FIRST
if not verify_signature(request.headers, body, WEBHOOK_SECRET):
raise HTTPException(401)
# 2. Return 2xx immediately — before any processing
background_tasks.add_task(process_event, json.loads(body))
return {"status": "accepted"}
Never do slow work (DB queries, API calls) in the webhook handler. Senders use short timeouts (a few seconds to tens of seconds, provider-specific) and retry on timeout, so a slow handler creates duplicate deliveries.
Security: Signature Verification¶
Every webhook provider should sign payloads, and every consumer should verify the signature over the raw request body before parsing it. GitHub-style signing (header X-Hub-Signature-256: sha256=<hex>):
import hmac, hashlib
def verify_signature(headers: dict, body: bytes, secret: str) -> bool:
expected = hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
received = headers.get("X-Hub-Signature-256", "").removeprefix("sha256=")
return hmac.compare_digest(expected, received)
Reliability Patterns¶
Webhook delivery is at-least-once: the sender retries on timeouts and non-2xx responses, so consumers must deduplicate by event ID and must not assume ordering. The signature above also lacks a timestamp, so it does not stop replay. Schemes such as Stripe's t=...,v1=... header and the Standard Webhooks spec (webhook-id, webhook-timestamp, webhook-signature) sign id.timestamp.body and let the consumer reject stale timestamps.
The implementation side (dispatcher architecture, timestamped signing code, retry schedule, dead-letter queue, replay) is in How-to Guides — Webhooks as a Product.
tRPC¶
tRPC lets TypeScript full-stack teams build APIs where type safety flows automatically from server to client — no code generation, no schema files, no out-of-sync types. The current major version is v11 (GA March 2025; 11.19.0 as of 2026-09). MIT licensed.
How It Works¶
- Define procedures on the server (TypeScript functions)
- Export the router type
- Import and use that type on the client
- TypeScript infers input/output types automatically
The client never imports server implementation code — only the type. At runtime, tRPC serializes calls over HTTP (queries → GET, mutations → POST, optional request batching). Subscriptions run over SSE (httpSubscriptionLink, the option the tRPC docs recommend in v11) or WebSocket (wsLink).
Routers and Procedures¶
// server/routers/users.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const userRouter = router({
// Query — GET /trpc/users.getById
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
return ctx.db.user.findUnique({ where: { id: input.id } });
}),
// Mutation — POST /trpc/users.create
create: protectedProcedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
return ctx.db.user.create({ data: input });
}),
});
// server/routers/_app.ts
export const appRouter = router({
users: userRouter,
posts: postRouter,
comments: commentRouter,
});
export type AppRouter = typeof appRouter; // ← this is all the client needs
Client Usage¶
The example uses the classic React Query integration. For new v11 projects the tRPC docs recommend the TanStack-native client (@trpc/tanstack-react-query, createTRPCContext), which exposes trpc.users.getById.queryOptions(...) for use with useQuery.
// client/trpc.ts (classic integration)
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
// In a React component:
function UserProfile({ userId }: { userId: string }) {
// Fully typed: input, output, error — all inferred from server code
const { data, isLoading } = trpc.users.getById.useQuery({ id: userId });
// data is typed as: User | null | undefined
// Change server return type → TypeScript error here immediately
}
Context and Middleware¶
// Context: per-request shared state (auth user, DB, etc.)
export const createContext = async ({ req, res }: CreateNextContextOptions) => ({
db: prisma,
session: await getSession({ req }),
});
// Middleware: wraps procedures with reusable logic
const isAuthenticated = middleware(({ ctx, next }) => {
if (!ctx.session?.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { ...ctx, user: ctx.session.user } });
});
// Protected procedure: any procedure using this is automatically auth-gated
const protectedProcedure = publicProcedure.use(isAuthenticated);
tRPC vs Alternatives¶
| Dimension | tRPC | REST + OpenAPI | GraphQL |
|---|---|---|---|
| Type safety | Automatic, no codegen | Code generation required | Code generation required |
| Language support | TypeScript/JS only | Universal | Universal |
| Schema file | None (types are the schema) | OpenAPI YAML/JSON | .graphql SDL |
| Learning curve | Low (just TypeScript) | Low | High |
| Client flexibility | tRPC client expected (raw HTTP possible but untyped) | Any HTTP client | Any GraphQL client |
| Over/under-fetching | Field selection not built in | Full response (unless sparse fieldsets) | Client specifies fields |
| Best for | TypeScript monorepos (T3 stack, Next.js) | Public APIs, polyglot | Complex multi-client frontends |
Choosing the Right API Paradigm¶
The decision flowchart in Protocol Comparison Overview encodes the usual rules: REST for public and partner APIs, GraphQL for many frontends with varied data needs, gRPC for internal service-to-service calls and streaming, WebSocket for bidirectional real-time traffic, SSE for server push, tRPC for a single TypeScript team, webhooks for notifying external systems, and SOAP only where an existing enterprise contract demands it.
It Is Not Either-Or
Real systems commonly use multiple paradigms together: a public REST API for external consumers, gRPC internally between microservices, GraphQL for the customer-facing frontend, WebSocket for real-time features, and webhooks for third-party integrations.
HTTP/2 and HTTP/3 (QUIC)¶
All modern API protocols ride on top of HTTP — understanding transport evolution is essential.
HTTP/2 (2015; current spec RFC 9113, 2022)¶
HTTP/2 was first published as RFC 7540 (2015). RFC 9113 (June 2022) obsoletes it and deprecates the original stream-priority scheme (RFC 9218 defines the replacement Priority header). HTTP/2 is the required transport for standard gRPC and improves REST/GraphQL performance through multiplexing.
| Feature | HTTP/1.1 | HTTP/2 |
|---|---|---|
| Framing | Text-based | Binary frames |
| Multiplexing | No (one in-flight request per TCP connection) | Yes, many streams per connection |
| Header compression | No | HPACK |
| Server push | No | Defined, but removed from Chrome (2022) and Firefox (2024). Use 103 Early Hints instead |
| Connection limit | ~6 per origin (browser) | 1 TCP connection, many streams (SETTINGS_MAX_CONCURRENT_STREAMS, often 100) |
| Head-of-line blocking | Yes, at the HTTP layer | Not at the HTTP layer, but still at the TCP layer |
The TCP head-of-line blocking problem: if a single TCP packet is lost, ALL HTTP/2 streams on that connection stall until retransmission completes. This is the fundamental limitation HTTP/3 solves.
HTTP/3 (2022, RFC 9114)¶
HTTP/3 (RFC 9114, June 2022) replaces TCP with QUIC (RFC 9000, a UDP-based transport with built-in TLS 1.3). Header compression changes from HPACK to QPACK (RFC 9204) because QUIC streams can arrive out of order. Clients discover HTTP/3 through the Alt-Svc header or DNS HTTPS records, then fall back to HTTP/2 over TCP if UDP is blocked.
graph TB
subgraph "HTTP/2 Stack"
H2[HTTP/2] --> TLS2[TLS 1.2/1.3]
TLS2 --> TCP[TCP]
TCP --> IP1[IP]
end
subgraph "HTTP/3 Stack"
H3[HTTP/3] --> QUIC["QUIC<br/>built-in TLS 1.3"]
QUIC --> UDP[UDP]
UDP --> IP2[IP]
end
Key improvements:
| Feature | HTTP/2 (TCP) | HTTP/3 (QUIC) |
|---|---|---|
| Head-of-line blocking | Yes, TCP-level | No, streams are independent |
| Connection setup | TCP + TLS 1.3 handshake (2 RTT; 3 RTT with TLS 1.2) | 1-RTT, or 0-RTT on resumption |
| Connection migration | No, new connection on network change | Yes, connection ID survives IP change |
| Packet loss recovery | Entire connection stalls | Only affected stream pauses |
| Congestion control | Kernel TCP (cubic/bbr) | User-space (pluggable, per-connection) |
Connection migration matters most for mobile APIs. When a phone switches from WiFi to cellular, HTTP/2 drops the TCP connection and must re-handshake. The HTTP/3 connection ID persists across network changes, so the connection continues.
0-RTT resumption: returning clients can send data in the very first packet. They reuse a previously negotiated TLS session. This matters for latency-sensitive API calls on mobile networks.
0-RTT Replay Risk
0-RTT data can be replayed by a network attacker. Accept 0-RTT only for safe, idempotent requests (GET). Servers and CDNs that are unsure can answer 425 Too Early (RFC 8470) so the client retries after the full handshake.
gRPC and HTTP/3: the gRPC wire spec is written for HTTP/2. gRFC G2 ("gRPC over HTTP/3", status Implemented) defines the mapping, but only grpc-dotnet implements it. grpc-go, grpc-java, and gRPC core have no official HTTP/3 transport as of 2026-09. Connect RPC in Go can run over HTTP/3 with a QUIC-capable net/http transport such as quic-go, which is outside the gRPC project.
Adoption: HTTP/3 is enabled by default in all major browsers. Server-side adoption was roughly 40% of the top 10 million websites in mid-2026 (W3Techs figure as cited by Wikipedia's HTTP/3 article; single source, treat as approximate).
Content Negotiation¶
Content negotiation lets client and server agree on response format:
# Client requests JSON, can accept XML as fallback
GET /v2/orders/42 HTTP/1.1
Accept: application/json, application/xml;q=0.9, */*;q=0.1
Accept-Language: en-US, fr;q=0.5
Accept-Encoding: gzip, br
# Server responds with chosen representation
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: en-US
Content-Encoding: br
Vary: Accept, Accept-Language, Accept-Encoding
The Vary header tells caches which request headers affect the response — critical for correct caching behavior.
API versioning via content negotiation:
This is the most RESTful versioning approach (no URL pollution) but less discoverable than URI versioning.
Architectural Patterns¶
Backend for Frontend (BFF)¶
The BFF pattern creates a dedicated API gateway per client type — each frontend gets an API layer optimized for its specific data needs.
flowchart LR
subgraph Clients
M[Mobile App]
W[Web App]
TV[Smart TV]
end
subgraph BFFLayer["BFF layer"]
MB["Mobile BFF<br/>Go / Node.js"]
WB["Web BFF<br/>Node.js"]
TB["TV BFF<br/>Node.js"]
end
subgraph Backend["Backend services (gRPC)"]
US[User Service]
PS[Product Service]
OS[Order Service]
end
M --> MB
W --> WB
TV --> TB
MB --> US & PS & OS
WB --> US & PS & OS
TB --> US & PS
Why BFF over a single gateway: - Mobile needs minimal payloads. Web needs rich data. One API cannot optimize for both - Each BFF aggregates multiple backend calls into one client-optimized response - Teams can deploy BFFs independently. Breaking a mobile BFF does not affect web - Authentication/session management can differ per client type
BFF vs GraphQL: GraphQL solves the over/under-fetching problem with client-specified queries. It can remove the need for separate BFFs. But BFF is still valuable in these cases: - Clients need significantly different business logic (not just different fields) - The team wants to contain complexity behind a simple REST API per client - Backend services expose gRPC — the BFF translates to REST/JSON for browser clients
GraphQL Persisted Queries¶
Persisted queries replace arbitrary client-sent GraphQL strings with pre-registered query IDs. This improves security, performance, and bandwidth.
# Without persisted queries — client sends full query string
POST /graphql
{"query": "query GetUser($id: ID!) { user(id: $id) { name email posts { title } } }", "variables": {"id": "42"}}
# With persisted queries — client sends only the hash
POST /graphql
{"extensions": {"persistedQuery": {"version": 1, "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4062f09b5bad56a6585"}}, "variables": {"id": "42"}}
Automatic persisted queries (APQ) flow (Apollo protocol; GraphQL Yoga supports it through a plugin):
sequenceDiagram
participant C as Apollo Client (persisted query link)
participant S as GraphQL server / Apollo Router
participant K as APQ cache (in-memory or Redis)
C->>S: GET /graphql?extensions={persistedQuery sha256Hash}
S->>K: Lookup hash
K-->>S: Miss
S-->>C: errors PERSISTED_QUERY_NOT_FOUND
C->>S: POST /graphql with query text + sha256Hash
S->>S: Verify SHA-256 of query text matches hash
S->>K: Store hash to query text
S-->>C: data
Note over C,S: Later requests send only the hash, as CDN-cacheable GETs
APQ is an optimization, not a security control: any client can register any query. Trusted documents (also called persisted query allowlists or safelisting) are the security variant. The build step extracts every operation from the client code, registers the hashes, and the server rejects anything not on the list.
Benefits:
- Security (trusted documents only): the server rejects any query not in the allowlist, which blocks arbitrary and malicious queries
- Bandwidth: hash (64 chars) replaces potentially multi-KB query strings
- CDN caching: hash-based GET requests are cacheable at edge (GET /graphql?extensions={...}&variables={...})
gRPC Health Checking Protocol¶
gRPC defines a standardized health checking protocol (grpc.health.v1) for load balancers and orchestrators:
syntax = "proto3";
package grpc.health.v1;
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}
message HealthCheckRequest {
string service = 1; // empty string = overall server health
}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
SERVICE_UNKNOWN = 3;
}
ServingStatus status = 1;
}
Kubernetes can call this service directly with a native grpc probe (stable since Kubernetes v1.27), so the older grpc_health_probe binary is no longer needed. Commands for checking health with grpcurl and a probe manifest are in How-to Guides — gRPC Testing.
gRPC Server Reflection¶
Server reflection lets tools like grpcurl discover services without .proto files. It is the gRPC equivalent of the OpenAPI /swagger.json:
// Enable reflection in Go gRPC server
import "google.golang.org/grpc/reflection"
s := grpc.NewServer()
pb.RegisterOrderServiceServer(s, &server{})
reflection.Register(s) // enables runtime schema discovery
With reflection on, grpcurl list and grpcurl describe work without local .proto files (see How-to Guides — gRPC Testing). The current service is grpc.reflection.v1.ServerReflection. Many tools still fall back to the older v1alpha name.
Disable Reflection in Production
Like GraphQL introspection, gRPC reflection exposes your entire API surface. Disable it in production or restrict to authorized callers only.
API Performance Patterns¶
Request Compression¶
# Client sends compressed body
POST /v2/orders HTTP/1.1
Content-Encoding: gzip
Content-Type: application/json
# Client requests compressed response
GET /v2/orders HTTP/1.1
Accept-Encoding: gzip, br
Brotli (br) usually compresses JSON and text somewhat better than gzip at comparable settings, but high Brotli levels cost much more CPU. CDNs pre-compress static assets with high-level Brotli. For dynamic API responses, use gzip or a low Brotli level (or zstd, which Chrome and Firefox now advertise in Accept-Encoding) and measure on your own payloads.
Connection Pooling¶
HTTP/1.1 clients must maintain a connection pool. A pool prevents the overhead of TCP+TLS handshakes per request:
| Setting | Typical Value | Notes |
|---|---|---|
| Pool size (per host) | 20–100 | Match to expected concurrency |
| Idle timeout | 30–90s | Close idle connections to free resources |
| Max lifetime | 5–10 min | Prevent sticky connections to a single backend |
| Health check interval | 10s | Detect dead connections proactively |
HTTP/2 clients typically use a single connection per host with unlimited streams — connection pooling is less critical but still relevant for fault tolerance (maintain 2–3 connections).
ETag-Based Conditional Requests¶
The request flow is shown under the Cacheable REST constraint above. ETags reduce bandwidth and server load. For mutable resources, use strong ETags (exact byte-for-byte match). For semantic equivalence, use weak ETags (W/"abc123"). The same ETag also enables optimistic concurrency: a client sends If-Match: "abc123" on PUT/PATCH and gets 412 Precondition Failed if someone else changed the resource first.
Async Request Collapsing (Request Deduplication)¶
When multiple clients request the same resource simultaneously, collapse them into a single backend request:
Time T=0: Client A → GET /products/42
Time T=1ms: Client B → GET /products/42 (same key, collapse)
Time T=2ms: Client C → GET /products/42 (same key, collapse)
Time T=50ms: Backend returns → fan out to A, B, C
Result: 1 backend call instead of 3
Implemented in: NGINX (proxy_cache_lock), Varnish (request coalescing, on by default), Cloudflare, and Go services via singleflight.
Benchmarks: Protocol Performance¶
Structural differences in encoding and per-request work between REST, GraphQL, and gRPC are in Reference — Benchmarks. No benchmark with published test conditions is recorded, so the page gives no size or latency multipliers. The practical lesson is that protocol overhead rarely dominates: database and downstream calls usually cost far more than JSON parsing, so pick a paradigm for its contract, tooling, and client fit first.
Security Model¶
The rest of this page is the API threat model: the OWASP API Security Top 10 (2023, still the latest edition in 2026-09), token attacks, protocol-specific attack surfaces, authorization models, and transport security. Look-up checklists (SSRF deny-list, security headers, TLS hardening) are in Reference — Hardening Checklists. The hands-on test script is in How-to Guides — API Security Testing.
The diagram marks where each control sits on an API request and which OWASP risk it addresses.
flowchart LR
ATK["Client or attacker"] -->|"TLS 1.2+/1.3"| WAF["CDN / WAF<br/>bot and flow controls (API6)"]
WAF --> GW["API gateway<br/>authN: OAuth 2.x JWT, mTLS, API keys (API2)<br/>rate limits, body size (API4)"]
GW --> APP["API handler"]
APP --> AUTHZ{"Authorization<br/>function-level (API5)<br/>object-level (API1)<br/>property-level (API3)"}
AUTHZ -->|"allowed"| DB[("Data store")]
APP -->|"outbound fetch of user URL"| EGR["Egress proxy<br/>SSRF deny-list (API7)"]
APP -->|"third-party API call"| TP["Partner API<br/>validate responses (API10)"]
INV["API inventory + OpenAPI specs<br/>(API9)"] -.-> GW
CFG["Config baseline<br/>headers, CORS, errors (API8)"] -.-> APP
OWASP API Security Top 10 (2023)¶
The OWASP API Security Top 10 is the authoritative classification of the most critical API vulnerabilities. The 2023 edition reflects the modern API threat landscape.
API1:2023 — Broken Object Level Authorization (BOLA)¶
BOLA is the most prevalent API vulnerability. The attacker manipulates resource IDs in the request to access objects that belong to other users.
# Attacker changes orderId to access another user's order
GET /api/v2/orders/order_OTHER_USER_123
Authorization: Bearer attacker_token
# Server returns the order without verifying ownership → BOLA
Root cause: Authorization checks run at the endpoint level but not at the object level. The code retrieves the object by ID without a check that it belongs to the authenticated user.
# VULNERABLE — fetches any order by ID
@app.get("/orders/{order_id}")
async def get_order(order_id: str, db: DB):
return db.orders.find_by_id(order_id) # no ownership check
# SECURE — scopes query to authenticated user
@app.get("/orders/{order_id}")
async def get_order(order_id: str, user: User = Depends(get_current_user), db: DB):
order = db.orders.find_one({"_id": order_id, "userId": user.id})
if not order:
raise HTTPException(404)
return order
Mitigations: - Enforce object-level authorization in every data access function - Use random, non-sequential IDs (UUIDs/ULIDs) — does NOT replace authorization but reduces enumeration - Write integration tests that specifically verify cross-user access is denied
API2:2023 — Broken Authentication¶
Weak or missing authentication mechanisms allow attackers to impersonate legitimate users.
Common weaknesses:
- No rate limiting on login/token endpoints → brute force
- Credentials in query strings (?api_key=secret) → logged by proxies, browsers, CDN
- No token expiration or excessively long TTL
- JWT alg: none accepted → forged tokens
- Password reset tokens that do not expire or are not single-use
Mitigations:
- Rate limit authentication endpoints aggressively (for example, 5 failures per minute per IP)
- Use the Authorization header only — never query params for secrets (OAuth 2.1 drops bearer tokens in query strings)
- Short-lived access tokens (for example, 5–15 min) + refresh tokens that rotate on use or are sender-constrained (OAuth 2.1, RFC 9700)
- Sender-constrain high-value tokens with mTLS (RFC 8705) or DPoP (RFC 9449) so a stolen token is useless without the private key
- Explicitly validate the JWT algorithm on the server — never trust the alg header (RFC 8725)
API3:2023 — Broken Object Property Level Authorization¶
This category combines the former "Excessive Data Exposure" and "Mass Assignment." The API exposes object properties that the user must not see, or it lets the user modify properties that they must not control.
// API response includes internal fields the client must not see
{
"id": "user_123",
"name": "Alice",
"email": "alice@example.com",
"role": "user",
"passwordHash": "$2b$12$...", // excessive data exposure
"internalCreditScore": 780, // excessive data exposure
"isAdmin": false // modifiable via mass assignment
}
# Mass assignment — attacker sends field they must not control
PATCH /api/v2/users/me
{"name": "Alice", "role": "admin", "isAdmin": true}
Mitigations:
- Explicit response schemas — allowlist fields per role, never return raw DB objects
- Input DTOs with strict field allowlists — reject unknown fields
- In Django REST Framework: use fields = (...) never fields = '__all__'
- Separate read/write schemas (GraphQL input types already enforce this)
API4:2023 — Unrestricted Resource Consumption¶
The API does not limit the size or number of resources that a client can request. This enables denial-of-service.
Attack vectors:
- No pagination limits → GET /users?limit=999999999
- Unbounded file uploads → 10 GB payload
- Expensive operations without rate limiting → repeated POST /reports
- Batch operations without bounds → POST /batch with 100K items
- GraphQL query depth/complexity bombs
Mitigations:
- Enforce max_page_size (for example, 100 items)
- Set maximum request body size (nginx: client_max_body_size 10m)
- Rate limit per user, per endpoint, and per expensive operation
- GraphQL: depth limiting + complexity scoring + persisted queries
- Set server-side timeouts for all operations
API5:2023 — Broken Function Level Authorization (BFLA)¶
Regular users can invoke administrative or privileged functions by calling the endpoint directly.
# Regular user discovers admin endpoint
DELETE /api/v2/admin/users/user_456
Authorization: Bearer regular_user_token
# Server processes it without checking role → BFLA
Mitigations:
- Deny by default — every endpoint requires explicit role mapping
- Separate admin routes with dedicated middleware: /admin/... with admin-only middleware
- Do not rely on client-side hiding of admin features
- Automated testing: enumerate all endpoints and verify each returns 403 for non-admin users
API6:2023 — Unrestricted Access to Sensitive Business Flows¶
Attackers automate legitimate business flows at scale (ticket scalping, coupon abuse, mass account creation, inventory hoarding).
Mitigations: - CAPTCHA / proof-of-work for sensitive flows - Device fingerprinting for anomaly detection - Rate limiting by business context (for example, max 3 coupons per user per day) - Bot detection (behavioral analysis, honeypot fields)
API7:2023 — Server-Side Request Forgery (SSRF)¶
The API accepts a URL from the user and fetches it server-side without validating the target.
// User-supplied webhook URL points to internal infrastructure
POST /api/v2/webhooks
{
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
}
// Server fetches AWS IMDS credentials → full cloud account compromise
Cloud metadata endpoints are the classic target because they hand out instance credentials. AWS IMDSv2 raises the bar by requiring a PUT-issued session token and a hop limit, but the application-level fix is still to block internal targets. The full list of addresses to block is in Reference — SSRF Deny-List.
Mitigations: - Validate and sanitize all user-supplied URLs - Block requests to private/reserved IP ranges (check the resolved IP, not the hostname, to defeat DNS rebinding) - Use an allowlist of permitted domains when possible - Disable HTTP redirects in outbound requests, or re-validate after each redirect - Run outbound requests from an isolated network zone (no access to IMDS or internal services)
API8:2023 — Security Misconfiguration¶
This is a broad category: missing security headers, verbose error messages, unnecessary HTTP methods, default credentials, and CORS misconfiguration.
These failures are usually defaults nobody changed: a framework that returns stack traces, a gateway that allows TRACE, CORS middleware that reflects any origin, or Swagger UI and GraphQL introspection left on in production. The secure baseline is in Reference — Security Misconfiguration Checklist.
API9:2023 — Improper Inventory Management¶
Organizations lose track of which API versions, endpoints, and environments are exposed. Shadow APIs, deprecated endpoints, and forgotten dev/staging environments become attack surfaces.
Mitigations:
- Maintain a complete API inventory (every endpoint, version, environment)
- Automate endpoint discovery from code (OpenAPI spec generation)
- Sunset deprecated API versions with Deprecation (RFC 9745) and Sunset (RFC 8594) headers, then return 410 Gone
- Network segmentation: dev/staging APIs must not be reachable from the internet
- Regular API surface audit: compare actual traffic to documented endpoints
API10:2023 — Unsafe Consumption of APIs¶
The API trusts data received from third-party APIs/services without validating it — the third party becomes an attack vector.
# VULNERABLE — trusts third-party response blindly
def enrich_user(user):
third_party_data = requests.get(f"https://partner-api.com/users/{user.id}").json()
user.name = third_party_data["name"] # could contain XSS payload
user.credit_limit = third_party_data["credit_limit"] # could be manipulated
user.save()
# SECURE — validate and sanitize
def enrich_user(user):
resp = requests.get(
f"https://partner-api.com/users/{user.id}",
timeout=5
)
resp.raise_for_status()
data = ThirdPartyUserSchema.model_validate(resp.json()) # Pydantic validation
user.name = bleach.clean(data.name)
user.save()
Mitigations: - Validate all third-party responses against a strict schema - Sanitize data before storing or rendering - Use timeouts and circuit breakers for all outbound calls - Apply the same security standards to consumed APIs as you apply to your own inputs
JWT Attack Vectors¶
Algorithm Confusion (None / HMAC → RSA)¶
Attack 1: alg:none
Attacker changes JWT header to {"alg": "none"}
Strips signature → server accepts unsigned token
Attack 2: RS256 → HS256
Server uses RS256 (public/private key pair)
Attacker sets alg to HS256 and signs with the PUBLIC key
Vulnerable server uses the public key as HMAC secret → signature validates
Defense: Never read the algorithm from the JWT header. Hardcode the expected algorithm (or a strict allowlist) on the server, as RFC 8725 (JWT Best Current Practices) requires:
// SECURE — explicitly specify expected algorithm
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(keyHMAC)).build();
DecodedJWT decodedToken = verifier.verify(token);
Token Sidejacking¶
If the JWT is stored in localStorage, XSS can steal it. If it is stored in a regular cookie, CSRF can use it.
Defense — Fingerprint binding:
1. On login, generate a random fingerprint
2. Store fingerprint hash in the JWT claims
3. Store fingerprint plaintext in a __Secure-Fgp httpOnly, secure, sameSite cookie
4. On each request, hash the cookie fingerprint and compare to the claim
This binds the token to the browser session — even if the JWT is stolen via XSS, the attacker cannot supply the httpOnly cookie.
JWK/JKU Injection¶
The attacker sets the jku (JWK Set URL) header to their own server, which hosts a crafted public key. Then they sign with the matching private key. The server fetches the key of the attacker, and the signature validates.
Defense: Never fetch keys from URLs in the JWT header (jku, x5u) or trust embedded keys (jwk). Use a JWKS endpoint configured on the server, usually discovered from the issuer's OAuth/OIDC metadata.
Protocol-Specific Security¶
REST Security¶
REST APIs inherit browser-facing risks (MIME sniffing, framing, caching of sensitive responses) whenever a browser can reach them. The header set that closes these off is in Reference — Security Response Headers.
Input validation rules: - Validate length, range, format, and type for all parameters - Use strong types (numbers, booleans, dates) — do not accept strings for everything - Constrain string inputs with regex - Reject request bodies exceeding size limits (HTTP 413) - Parse XML with XXE protections (disable external entities, DTD processing) - Log input validation failures — a spike indicates probing
GraphQL Security¶
GraphQL has a unique attack surface because of its flexibility:
| Threat | Attack | Defense |
|---|---|---|
| Introspection abuse | Attacker queries __schema to map the entire API |
Disable introspection in production |
| Depth bomb | { user { friends { friends { friends { ... } } } } } |
Depth limiting (for example, max 10 levels) |
| Width bomb | Request all fields on hundreds of objects | Complexity scoring per field |
| Batch attack | Send array of mutations in one request | Limit batch size |
BOLA via node field |
{ node(id: "OTHER_USER_ID") { ... on User { email } } } |
Remove node/nodes relay fields or enforce authorization |
| Field suggestion leak | Typo returns "Did you mean X?" → reveals schema | Disable field suggestions in production |
| Alias / batching brute force | Hundreds of aliased login mutations in one request bypass per-request rate limits |
Count aliases and operations toward rate limits, cap batch size |
| Arbitrary operations | Any client-crafted query reaches the resolvers | Trusted documents (persisted query allowlist) for first-party clients |
graphql-shield authorization example:
import { rule, shield, and, or, not } from "graphql-shield";
const isAuthenticated = rule({ cache: "contextual" })(
async (parent, args, ctx, info) => ctx.user !== null
);
const isAdmin = rule({ cache: "contextual" })(
async (parent, args, ctx, info) => ctx.user.role === "admin"
);
const permissions = shield({
Query: {
users: and(isAuthenticated, isAdmin),
me: isAuthenticated,
},
Mutation: {
deleteUser: and(isAuthenticated, isAdmin),
updateProfile: isAuthenticated,
},
User: {
email: isAuthenticated,
passwordHash: isAdmin, // only admins can see this field
},
});
Check for relay node exposure:
cat schema.json | jq '.data.__schema.types[] | select(.name=="Query") | .fields[] | .name' | grep node
gRPC Security¶
Transport security:
- Always use TLS in production — grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))
- For internal service mesh: mTLS via Istio, Linkerd, or SPIFFE/SPIRE identities
- Never use plaintext credentials outside local development. In grpc-go, grpc.WithInsecure() is deprecated; the explicit replacement is grpc.WithTransportCredentials(insecure.NewCredentials())
Authentication interceptors:
// API key validation from metadata
func validateAPIKey(ctx context.Context) error {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return status.Error(codes.Unauthenticated, "missing metadata")
}
keys := md["x-api-key"]
if len(keys) == 0 || !isValidAPIKey(keys[0]) {
return status.Error(codes.Unauthenticated, "invalid API key")
}
return nil
}
Protobuf input validation (protovalidate):
protoc-gen-validate (PGV) is in maintenance mode. Buf recommends migrating to protovalidate, which evaluates CEL-based rules at runtime without generated validator code:
syntax = "proto3";
import "buf/validate/validate.proto";
message CreateUserRequest {
string email = 1 [(buf.validate.field).string.email = true];
string name = 2 [(buf.validate.field).string = {min_len: 1, max_len: 100}];
int32 age = 3 [(buf.validate.field).int32 = {gte: 0, lte: 150}];
}
Test commands (calls without a token, with an invalid token, and with a valid token) are in How-to Guides — gRPC Testing. The assessment checklist is in Reference — gRPC Security Checklist.
WebSocket Security¶
| Threat | Attack | Defense |
|---|---|---|
| No origin check | Cross-site WebSocket hijacking (CSWSH) | Validate Origin header during handshake |
| Missing auth | Unauthenticated connections | Authenticate during the handshake (cookie, or a short-lived ticket in the query string because the browser WebSocket API cannot set custom headers) or in the first message |
| Injection | Malicious payloads in messages | Validate and sanitize all incoming messages |
| Data exfiltration | Sensitive data over unencrypted WS | Always use wss:// (WebSocket over TLS) |
| Resource exhaustion | Opening thousands of connections | Per-IP connection limits, idle timeout |
// Server-side: validate origin during upgrade
server.on('upgrade', (request, socket, head) => {
const origin = request.headers.origin;
if (!ALLOWED_ORIGINS.includes(origin)) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
Webhook Security¶
| Threat | Defense |
|---|---|
| Forged payloads | HMAC-SHA256 signature verification (see apis/web-services/how-to-guides#payload-signing-hmac-sha256) |
| Replay attacks | Include a timestamp in the signed content and reject deliveries older than about 5 minutes |
| DDoS via webhook floods | Rate limit incoming webhook requests. Queue for async processing |
| Sensitive data in transit | HTTPS only. Verify the TLS certificate of the webhook consumer |
| SSRF from webhook URLs | Validate registered URLs against deny-list (RFC 1918, cloud IMDS) |
Authorization Patterns¶
RBAC (Role-Based Access Control)¶
Administrators assign roles to users. Roles map to permissions. RBAC is simple, well-understood, and widely adopted.
Roles: admin, editor, viewer
Permissions: orders:read, orders:write, orders:delete, users:manage
Mapping:
admin → orders:read, orders:write, orders:delete, users:manage
editor → orders:read, orders:write
viewer → orders:read
# Middleware check
def require_permission(permission: str):
def decorator(func):
@wraps(func)
async def wrapper(request, *args, **kwargs):
user = request.state.user
if permission not in user.permissions:
raise HTTPException(403, "Insufficient permissions")
return await func(request, *args, **kwargs)
return wrapper
return decorator
@app.delete("/orders/{order_id}")
@require_permission("orders:delete")
async def delete_order(order_id: str):
...
Limitation: RBAC does not handle contextual decisions well (for example, "can edit only their own orders" or "can access only orders from their department").
ABAC (Attribute-Based Access Control)¶
Policies evaluate attributes of the user, resource, action, and environment at decision time.
Policy: ALLOW if
user.department == resource.department AND
action == "read" AND
environment.time BETWEEN 09:00 AND 18:00
ABAC is more expressive than RBAC but more complex to implement and audit. Cloud IAM systems support it on top of roles: AWS IAM tag-based conditions, Google Cloud IAM Conditions, and Azure ABAC role-assignment conditions. Policy engines such as OPA (Rego) and AWS Cedar express ABAC policies outside application code.
ReBAC (Relationship-Based Access Control)¶
ReBAC authorizes on the relationship between user and resource, not just roles. The model comes from Google's Zanzibar paper (2019). Open implementations include OpenFGA (CNCF incubating since 2025-10), SpiceDB (AuthZed), Permify, and Ory Keto.
Tuples:
document:budget-2026#viewer@user:alice
document:budget-2026#editor@user:bob
folder:finance#viewer@group:accounting
Check: can user:alice view document:budget-2026?
→ YES (direct viewer relationship)
ReBAC works best for document-sharing, multi-tenant SaaS, and social graph-based permissions.
Transport Security¶
TLS Configuration¶
TLS protects every API paradigm on this page, and TLS 1.3 matters beyond confidentiality: it cuts the handshake to one round trip and removes legacy cipher suites. The trend in 2025–2026 is toward shorter-lived certificates and less online revocation checking. CA/Browser Forum rules cut maximum certificate lifetime to 200 days in March 2026 (47 days by 2029), and Let's Encrypt shut down OCSP in August 2025. Both push operators toward fully automated renewal. An NGINX baseline config is in How-to Guides — TLS Configuration. The checklist is in Reference — TLS Hardening Checklist.
Certificate Pinning¶
Pin expected server certificate or public key hash in the client to prevent MITM attacks via compromised CAs.
# Get pin hash from certificate
openssl x509 -in server.crt -pubkey -noout | \
openssl pkey -pubin -outform der | \
openssl dgst -sha256 -binary | base64
Certificate Pinning Trade-offs
Pinning increases security against CA compromise but creates operational risk — certificate rotation requires synchronized client updates. With leaf lifetimes falling to 200 days (2026) and 47 days (2029), pin the public key (SPKI) of a CA or an intermediate you control rather than a leaf certificate, ship backup pins, and roll out gradually. Browsers dropped HTTP Public Key Pinning (HPKP) for exactly this reason. Pinning is mainly a mobile-app and service-to-service technique.
API Security Testing¶
Security testing for APIs combines automated scanners (DAST, SAST, fuzzers driven by the OpenAPI or GraphQL schema) with targeted manual checks for authorization flaws. Scanners find misconfiguration and injection well, but BOLA and BFLA need tests that know which user owns which object. The tool matrix is in Reference — Security Testing Tools. The manual test script is in How-to Guides — API Security Testing.
Sources¶
Protocols and Specifications¶
- RFC 9110 HTTP Semantics, RFC 9113 HTTP/2, RFC 9114 HTTP/3, RFC 9000 QUIC
- RFC 10008 The HTTP QUERY Method
- RFC 6455 WebSocket, RFC 8441 WebSocket over HTTP/2, RFC 9220 WebSocket over HTTP/3
- Server-sent events — WHATWG HTML
- GraphQL specification, September 2025 edition
- Apollo Federation documentation
- GraphQL Composite Schemas spec
- gRPC over HTTP/2 protocol and gRFC G2: gRPC over HTTP/3
- Protobuf Editions overview
- Connect RPC
- tRPC subscriptions docs
- Standard Webhooks specification
- Removing HTTP/2 Server Push from Chrome
OWASP¶
- OWASP API Security Top 10 (2023)
- OWASP REST Security Cheat Sheet
- OWASP GraphQL Cheat Sheet
- OWASP gRPC Security Cheat Sheet
- OWASP JWT for Java Cheat Sheet
- OWASP IDOR Prevention Cheat Sheet
- OWASP SSRF Prevention Cheat Sheet
- OWASP OAuth 2.0 Cheat Sheet
JWT & OAuth¶
- JSON Web Tokens — RFC 7519
- OAuth 2.0 — RFC 6749
- OAuth 2.1 Draft
- PKCE — RFC 7636
- OAuth 2.0 Security Best Current Practice — RFC 9700
- DPoP — RFC 9449
- JWT Best Current Practices — RFC 8725
GraphQL Security¶
- GraphQL Security Best Practices — graphql.org
- GraphQL Authorization — graphql.org
- graphql-shield — GitHub