Protocol Translation Patterns at the API Gateway
Modern distributed systems are rarely monoglot. A mobile client speaks HTTP/1.1 JSON, an internal analytics pipeline speaks gRPC, a legacy integration layer expects SOAP/XML, and a real-time dashboard holds a long-lived WebSocket. The API gateway sits at the intersection of all of these, and its protocol translation layer is what lets every consumer speak its native dialect while backend services stay decoupled from transport concerns. This page covers the middleware pipeline design, runnable configuration for Kong 3.x and Envoy 1.32+, security context propagation, and the observability plumbing required to operate translation at production scale.
Architectural Baseline
Before implementing any translation, three mental-model shifts are required.
Translation is not proxying. A plain reverse proxy forwards bytes; a translation gateway must parse, validate, remap, and re-encode. This makes the gateway stateful with respect to schema — it must hold or resolve the source and target schema (OpenAPI spec, protobuf descriptor) at request time, not at boot time.
The canonical internal representation matters. Production translation pipelines decode the inbound envelope into a normalised in-memory structure, apply transformation rules, then re-encode for the upstream. Skipping this step and chaining pairwise converters (HTTP to gRPC then gRPC to AMQP) produces an exponentially growing set of code paths and breaks tracing context across the middleware chain.
Protocol mismatch is a schema mismatch in disguise. The hard problems in HTTP/1.1-to-gRPC translation are not byte-level framing; they are field naming conventions (userId vs user_id), null/absent field semantics, streaming vs request-response lifecycle, and deadline representation. Plan for schema governance before writing gateway config.
The diagram below shows how a single inbound request traverses a translation gateway from inbound protocol detection through to upstream dispatch:
Primary Concept: gRPC-to-REST and REST-to-gRPC Translation
This is the most common translation requirement. An external REST consumer must reach an internal gRPC service, or an internal gRPC service must call an external REST API. The gateway must handle protobuf serialization, HTTP/2 framing, status code normalisation, and deadline forwarding in both directions.
Kong 3.x: grpc-gateway plugin
Kong’s grpc-gateway plugin exposes a gRPC service over a REST/JSON interface. The plugin loads the proto descriptor at startup and uses it to transcode HTTP/JSON requests to protobuf frames.
# Kong 3.x — deck state file excerpt
_format_version: "3.0"
services:
- name: user-grpc-service
protocol: grpc
host: user-svc.internal
port: 50051
routes:
- name: user-rest-route
protocols: [http, https]
paths: ["/api/v1/users"]
methods: [GET, POST, PUT, DELETE]
plugins:
- name: grpc-gateway
service: user-grpc-service
config:
# Path to the compiled FileDescriptorSet (.pb file)
proto: /etc/kong/protos/user_service.pb
The proto descriptor file must be compiled with --include_imports to embed all dependencies:
# Compile descriptor — pin to protoc 25.x for deterministic output
protoc --proto_path=./protos \
--descriptor_set_out=/etc/kong/protos/user_service.pb \
--include_imports \
user_service.proto
Status code mapping in Kong follows the gRPC to HTTP table by default (INVALID_ARGUMENT maps to 400, NOT_FOUND to 404, UNAVAILABLE to 503), but you can override per-route in the grpc-gateway config using an error_handler Lua block.
For field name translation (userId to user_id), the plugin respects the json_name option in the proto definition. Set it explicitly rather than relying on protoc’s default camelCase conversion:
// user_service.proto — proto3
message GetUserRequest {
string user_id = 1 [json_name = "userId"];
}
The complete guide — including buffer management, connection draining under high throughput, and protobuf reflection — is covered in depth in Handling gRPC to REST Translation at Scale.
Envoy 1.32+: grpc_json_transcoder filter
Envoy’s grpc_json_transcoder filter provides the same REST-to-gRPC transcoding natively without a plugin layer. Because Envoy manages its own HTTP/2 connection pool per upstream cluster, there is no additional hop for the translation.
# Envoy 1.32+ — static filter chain snippet
http_filters:
- name: envoy.filters.http.grpc_json_transcoder
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_json_transcoder.v3.GrpcJsonTranscoder
proto_descriptor: /etc/envoy/protos/user_service.pb
services:
- user.v1.UserService
print_options:
add_whitespace: true
always_print_primitive_fields: true
preserve_proto_field_names: false # enforce camelCase in JSON responses
convert_grpc_status: true
request_validation_options:
reject_unknown_method: true
reject_unknown_query_parameters: true
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
The corresponding proto must use google.api.http annotations to describe the REST binding:
import "google/api/annotations.proto";
service UserService {
rpc GetUser (GetUserRequest) returns (User) {
option (google.api.http) = {
get: "/api/v1/users/{user_id}"
};
}
rpc CreateUser (CreateUserRequest) returns (User) {
option (google.api.http) = {
post: "/api/v1/users"
body: "*"
};
}
}
Deadline propagation across protocols: gRPC uses a grpc-timeout header (e.g., 100m for 100 milliseconds). REST clients typically use x-request-timeout or a custom header. Envoy’s transcoder converts the HTTP-level timeout from the route configuration into a grpc-timeout header automatically. On Kong, you must do this explicitly in a pre-function plugin:
-- Kong 3.x pre-function plugin: map x-request-timeout to grpc-timeout
local timeout_ms = kong.request.get_header("x-request-timeout")
if timeout_ms then
kong.service.request.set_header("grpc-timeout", timeout_ms .. "m")
end
Secondary Concept: WebSocket Proxying and Protocol Upgrades
WebSocket sessions cross a protocol boundary that most gateway configurations handle poorly. The upgrade handshake (HTTP/1.1 101 Switching Protocols) must pass through the gateway intact, and once the connection upgrades, the gateway must maintain a persistent bidirectional pipe rather than a request-response cycle. This breaks naive connection pool assumptions.
Connection affinity for WebSocket sessions
WebSocket and long-lived server-sent event (SSE) streams require sticky routing, not round-robin. If the gateway rebalances a WebSocket connection mid-session to a different upstream, the application-layer session state is lost. Use consistent hashing on the session identifier rather than the client IP — IPs change under NAT and CGNAT, but the session cookie or token remains stable.
Kong 3.x — WebSocket sticky routing:
# Kong 3.x — WebSocket upstream with hash-based balancing
upstreams:
- name: ws-backend
algorithm: consistent-hashing
hash_on: header
hash_on_header: "x-session-id"
hash_fallback: cookie
hash_fallback_header: "session"
healthchecks:
active:
healthy:
interval: 10
successes: 2
unhealthy:
interval: 5
http_failures: 3
services:
- name: websocket-service
host: ws-backend
protocol: ws # or wss for TLS upstream
routes:
- name: ws-route
protocols: [http, https] # gateway handles upgrade
paths: ["/ws"]
Envoy 1.32+ — WebSocket upgrade filter:
# Envoy 1.32+ — allow WebSocket upgrade on a specific route
virtual_hosts:
- name: ws-vhost
domains: ["*"]
routes:
- match:
prefix: "/ws"
headers:
- name: "Upgrade"
string_match:
exact: "websocket"
route:
cluster: ws-backend-cluster
upgrade_configs:
- upgrade_type: "websocket"
enabled: true
timeout: 0s # disable idle timeout for long-lived connections
idle_timeout: 900s # but set a hard 15-minute inactivity limit
HTTP/1.1 to HTTP/2 upstream translation
Envoy performs h2c (HTTP/2 cleartext) upstream by default when the upstream http2_protocol_options key is present. Kong requires the service protocol set to http2 or grpcs. The gateway holds a multiplexed HTTP/2 connection pool to each upstream host, meaning thousands of logical streams share a small number of TCP sockets — a significant efficiency gain over HTTP/1.1 connection-per-request pooling.
The risk here is head-of-line blocking at the TCP layer. A single large response body can stall all other streams on the same TCP connection. Mitigate this by setting a reasonable max_concurrent_streams limit (Envoy default: 100) and by enabling SETTINGS_INITIAL_WINDOW_SIZE tuning:
# Envoy 1.32+ — HTTP/2 upstream pool tuning
clusters:
- name: grpc-backend
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options:
max_concurrent_streams: 200
initial_stream_window_size: 65536 # 64 KB
initial_connection_window_size: 1048576 # 1 MB
Comparative Implementation Table
| Gateway | Protocol pair | Config mechanism | Key trade-off |
|---|---|---|---|
| Kong 3.x | REST to gRPC | grpc-gateway plugin + .pb descriptor |
Hot-reloadable via Admin API; descriptor reload requires plugin config update |
| Kong 3.x | HTTP/1.1 to HTTP/2 upstream | Service protocol: http2 |
No filter-level stream tuning; relies on Nginx upstream defaults |
| Kong 3.x | HTTP to WebSocket | Service protocol: ws; upstream hash balancing |
Sticky routing via consistent-hashing; no native SSE-specific handling |
| Envoy 1.32+ | REST to gRPC | grpc_json_transcoder filter + proto annotations |
Requires google.api.http annotations baked into proto; tighter coupling to HTTP binding |
| Envoy 1.32+ | HTTP/1.1 to HTTP/2 | http2_protocol_options on cluster |
Fine-grained stream/window tuning; xDS dynamic update without restart |
| Envoy 1.32+ | HTTP to WebSocket | upgrade_configs on route |
Per-route upgrade control; timeout must be manually set to 0s |
| Tyk 5.x | REST to gRPC | proxy.transport.tls + grpc listen type |
Native gRPC listener but no built-in REST transcoding; requires an external transcoding sidecar |
Security Context Preservation Across Protocol Boundaries
Protocol translation physically alters the request envelope, which can silently strip authentication headers, mutate TLS termination state, or obscure the original client identity from downstream services. The gateway must explicitly re-inject security context after translation — this is not automatic.
This requirement sits within the broader security boundaries and zero-trust model, where downstream services must never assume the integrity of the upstream transport layer.
Security context mapping by protocol pair
| Inbound | Auth mechanism | Outbound | Preservation strategy |
|---|---|---|---|
| HTTP/1.1 REST | Bearer JWT in Authorization |
gRPC | Copy Authorization header into gRPC metadata as authorization key |
| gRPC | mTLS client certificate | REST/JSON | Extract Subject DN from client cert; inject as x-forwarded-client-cert (XFCC) header |
| WebSocket | Session cookie | gRPC-Web | Map cookie to gRPC metadata session key; enforce SameSite=Strict; Secure on cookie |
| HTTP/2 | JWT in Authorization |
HTTP/1.1 upstream | Pass through Authorization; strip hop-by-hop headers (:method, :path, :scheme) |
After translation, the gateway should re-sign outbound service-to-service requests using short-lived tokens (e.g., SPIFFE/SPIRE SVIDs or gateway-issued JWTs scoped to the specific upstream service). Never allow backend services to receive translated requests without an explicit trust assertion from the gateway.
Strip all client-originated headers that could influence routing or authorization on the upstream:
# Kong 3.x — request-transformer plugin: strip dangerous upstream headers
plugins:
- name: request-transformer
config:
remove:
headers:
- x-forwarded-for # re-add from trusted source only
- x-real-ip
- x-custom-auth # any auth header the client could forge
- x-grpc-status # gRPC status should come from upstream only
Observability and Telemetry for Translation Layers
Distributed tracing breaks when protocol boundaries are crossed without explicit context propagation. The gateway must extract W3C Trace Context from the inbound protocol and re-inject it in the target format before forwarding upstream.
For gRPC upstreams, traceparent maps to the grpc-trace-bin binary header (base64-encoded). Envoy’s zipkin or opentelemetry tracer handles this automatically. In Kong, use the opentelemetry plugin with propagation.extract_from = {"w3c"} and propagation.inject_into = {"w3c", "b3"}. The request and response transformation layer runs before this injection step, so trace headers set by upstream must not be inadvertently stripped during JSON field mapping.
Key span attributes to attach at the translation stage:
translation.protocol_in— e.g.,http1.1,grpc-webtranslation.protocol_out— e.g.,grpc,http2translation.schema_version— proto file package + version stringtranslation.duration_ms— time spent in transcoding, excluding upstream RTT
Metric counters to alert on:
schema.validation_failures_total— a non-zero rate indicates a proto drift incident or a client sending malformed payloadstranslation.timeout_total— upstream deadline exceeded before translation completed; indicates a sizing issuews.connections_active— should not grow unboundedly; a leak here means WebSocket teardown is not cleaning up file descriptors
Set SLO thresholds: p99 transcoding latency below 15 ms for REST-to-gRPC on payloads under 64 KB; below 5 ms for HTTP/1.1-to-HTTP/2 passthrough where no body transformation is required. Sustained breaches above 50 ms p99 indicate connection pool saturation or schema registry resolution latency — check upstream_connect_time metrics first.
Operational Gotchas
1. Trailing metadata frames in gRPC streaming are dropped.
gRPC unary responses send grpc-status and grpc-message as HTTP/2 trailers, not headers. HTTP/1.1 has no trailer support by default. When translating a streaming gRPC response to HTTP/1.1, trailers are silently discarded unless you use chunked transfer encoding with TE: trailers declared by the client. Test this explicitly with grpcurl and an HTTP/1.1 proxy in the path.
2. Compression mismatches increase payload size.
If the upstream gRPC service sends grpc-encoding: gzip but the REST client does not declare Accept-Encoding: gzip, the gateway must decompress before forwarding — a CPU spike under high load. Disable per-message gRPC compression at the upstream when running behind a translation gateway that handles transport-level TLS compression independently.
3. Deadline and timeout mismatch causes orphaned upstream calls.
When an external REST client closes the connection before the gateway receives an upstream response, most gateways cancel the outbound request. However, gRPC streams with grpc-timeout set to a value longer than the gateway’s upstream_connect_timeout may not propagate cancellation (RST_STREAM) correctly. Verify with a deliberate early-disconnect test and confirm the upstream service receives the cancellation signal.
4. Schema drift between proto descriptor and running service.
The gateway loads a compiled .pb descriptor at config time. If the upstream service deploys a new proto version without updating the gateway descriptor, field additions are silently dropped (proto3 compatible) but field removals or type changes cause serialization failures that surface as opaque INTERNAL errors. Implement a CI gate that validates the gateway descriptor against the upstream proto registry on every service deployment.
5. WebSocket connections exhaust file descriptor limits.
Each WebSocket session holds an open file descriptor on both the gateway and upstream. At 10,000 concurrent sessions, the gateway process will hit the default OS limit (ulimit -n 65535 on most Linux distributions). Increase worker_rlimit_nofile in Nginx-backed gateways and set LimitNOFILE in the systemd unit for Envoy.
6. HTTP/2 ALPN negotiation fails silently for old TLS stacks.
If a client’s TLS library does not support ALPN (TLS 1.2 without ALPN extension), the gateway falls back to HTTP/1.1 without warning. The client then sends HTTP/2 frames over an HTTP/1.1 connection, which produces a 400 Bad Request. Add explicit ALPN negotiation logging at DEBUG level during rollout. Consult Gateway Selection Criteria for a capability matrix comparing ALPN support across Kong, Envoy, Tyk, and NGINX.
Production Configuration Checklist
- Proto descriptor compiled with
--include_importsand versioned in the same CI pipeline as the upstream service -
json_nameannotations set on every proto message field; camelCase mapping verified in integration tests -
grpc-timeoutheader propagated from REST client timeout header; maximum enforced at gateway level - gRPC status to HTTP status mapping table documented and tested for all error paths (400, 401, 403, 404, 429, 500, 503)
- WebSocket sticky routing configured with session-token-based consistent hashing, not IP hashing
- HTTP/2 upstream
max_concurrent_streamsset and load-tested at 120% of expected peak concurrency - Security context headers (
Authorization, XFCC) explicitly forwarded; client-forgeable headers stripped inrequest-transformer - File descriptor limits raised (
LimitNOFILE>= 500000) on gateway host for WebSocket workloads - W3C
traceparent/tracestateheaders mapped togrpc-trace-binfor gRPC upstreams - Schema registry alert configured: gateway deployment blocked if descriptor diverges from upstream proto
- Translation latency SLO defined: p99 <= 15 ms for stateless REST-to-gRPC; <= 5 ms for HTTP/1.1-to-HTTP/2
FAQ
What is the latency overhead of gRPC-to-REST translation at the gateway?
Stateless protobuf-to-JSON transcoding typically adds 2–8 ms per request depending on payload size and schema complexity. Streaming endpoints carry higher overhead because each message frame must be individually transcoded. Using a compiled proto descriptor cache and connection reuse keeps p99 latency below 15 ms for payloads under 64 KB.
How do I propagate mTLS identity across a protocol translation boundary?
The gateway terminates the inbound mTLS session, extracts the client certificate’s Subject and SAN fields, and injects them as downstream HTTP headers — typically x-forwarded-client-cert (XFCC) for Envoy or a custom x-client-cert-dn header for Kong. The downstream service must treat these headers as authoritative only when they arrive from the gateway’s internal network range, never from external callers.
Can a single gateway route both HTTP/1.1 and HTTP/2 on port 443?
Yes. Envoy and Kong both support ALPN negotiation on a single listener: the gateway advertises h2 and http/1.1 in the TLS handshake and selects the filter chain based on the negotiated protocol. The upstream connection pool is independent — the gateway can translate HTTP/1.1 inbound to HTTP/2 upstream transparently.
What happens to gRPC trailers when translating to HTTP/1.1?
gRPC unary responses send grpc-status and grpc-message as HTTP/2 trailers, not headers. HTTP/1.1 has no trailer support by default. When translating a streaming gRPC response to HTTP/1.1, trailers are silently discarded unless you use chunked transfer encoding with TE: trailers declared by the client. Test this path explicitly — the failure mode is silent data loss, not an error response.
Related
- Handling gRPC to REST Translation at Scale — buffer management, connection draining, and protobuf reflection under high throughput
- Envoy xDS vs Tyk for Protocol Translation — comparing the two control-plane models and transcoding fidelity
- Security Boundaries & Zero Trust — mTLS, SPIFFE/SPIRE, and identity propagation across service boundaries
- High Availability Topologies — how translation layer placement affects failover and load distribution
- Rate Limiting & Throttling Strategies — applying rate limits at the translation boundary for gRPC and WebSocket traffic
- Caching & Response Optimization — caching translated REST responses when upstream is gRPC