Envoy API Gateway
Architecture and configuration of the Envoy API gateway for self-hosted Supabase.
Self-hosted Supabase ships with an optional Envoy-based API gateway. It accepts incoming client requests, routes them to internal services (Auth, PostgREST, Realtime, Storage, Edge Functions, postgres-meta, Studio), and enforces API key authentication by translating opaque sb_ keys into the internal credentials used by those services.
This guide explains the architecture, configuration layout, and security posture of the Envoy gateway for operators who want to understand or customize it. It is not an Envoy tutorial - for reference on filters, routes, and clusters, see the Envoy documentation.
Before you begin#
- Complete the Self-Hosting with Docker setup
- To enable opaque
sb_key translation, see New API Keys and Asymmetric Authentication
Enabling the Envoy gateway#
The Envoy gateway is provided as a Docker Compose override.
If your stack is already running from the initial setup, bring it down first with docker compose down.
Start your self-hosted Supabase stack with both the base compose file and the Envoy override:
1docker compose -f docker-compose.yml -f docker-compose.envoy.yml up -dThe override disables the default Kong gateway and starts Envoy on the same port (default 8000). It also reconfigures the Functions service to wait for Envoy via a dependency.
Envoy is registered as the api-gw service and also exposes kong as a network alias; the base Kong service likewise exposes api-gw. Either hostname resolves to whichever gateway is currently active, so internal configs that hardcode kong:8000 (for example, in Edge Functions or Studio) keep working without changes.
Verify#
Confirm the gateway is routing requests and enforcing API keys:
1curl -i -H "apikey: your-service-role-key" http://<your-domain>/rest/v1/A 200 OK response from PostgREST confirms the gateway is up. A 401 Unauthorized without the apikey header confirms enforcement is active.
Architecture#
Envoy runs as a single container (supabase-envoy) with one listener on port 8000. Every incoming request passes through an ordered chain of HTTP filters before being forwarded to an upstream cluster:
1Client2 │3 ▼4Listener (port 8000)5 │6 ▼7HTTP filter chain8 ├─ CORS9 ├─ Basic Auth (dashboard only)10 ├─ Lua: copy ?apikey query to header11 ├─ Lua: translate opaque keys in query12 ├─ Lua: translate opaque keys in header13 ├─ Lua: mirror apikey to x-api-key (Realtime WS)14 ├─ Lua: synthesize Authorization header15 ├─ Lua: 401 for missing/invalid API key16 ├─ RBAC (global: service_role → /pg/, apikey → other API routes;17 │ per-route DENY override on /mcp)18 └─ Router19 │20 ▼21Upstream clusters22 ├─ auth (auth:9999)23 ├─ rest (rest:3000)24 ├─ realtime (realtime-dev.supabase-realtime:4000)25 ├─ storage (storage:5000)26 ├─ functions (functions:9000)27 ├─ meta (meta:8080)28 └─ studio (studio:3000)Routes are matched in the order declared in the listener config. Each route selects an upstream cluster, rewrites the request path prefix, and can override filter behavior (for example, disabling basic auth on API routes or denying all traffic on MCP routes).
Configuration file structure#
All Envoy configuration lives in ./volumes/api/envoy/ (relative to the directory containing docker-compose.yml):
| File | Purpose |
|---|---|
envoy.yaml | Bootstrap configuration. Points Envoy at the CDS and LDS files, configures the admin interface, and sets an overload manager limit on downstream connections. |
cds.yaml | Cluster Discovery Service - upstream service definitions (DNS, ports, health checks, connect timeouts, circuit breakers). |
lds.template.yaml | Listener Discovery Service template. Defines the listener, filter chain, routes, RBAC policy, and CORS policy. Contains placeholders for keys and credentials. |
docker-entrypoint.sh | Renders the LDS template into lds.yaml at container startup by substituting environment variables, computes a SHA1+base64 hash of DASHBOARD_PASSWORD for basic auth, then starts Envoy. |
How the configuration is rendered at startup#
Envoy cannot natively read environment variables inside its config. The entrypoint script renders environment-specific values into the listener config with sed before launching Envoy:
- A SHA1+base64 hash of
DASHBOARD_PASSWORDis computed and joined withDASHBOARD_USERNAMEinto a singleDASHBOARD_BASIC_AUTHstring in theusername:{SHA}<base64-encoded-sha1>format that Envoy'sbasic_authfilter expects. Other hash formats are not supported. - The following variables are then substituted into
lds.template.yamland the result is written tolds.yaml:
| Variable | Used for |
|---|---|
ANON_KEY | Legacy HS256 anon JWT (API key validation, RBAC) |
SERVICE_ROLE_KEY | Legacy HS256 service_role JWT (API key validation, RBAC) |
SUPABASE_PUBLISHABLE_KEY | Opaque sb_publishable_* key (translation source) |
SUPABASE_SECRET_KEY | Opaque sb_secret_* key (translation source) |
ANON_KEY_ASYMMETRIC | Pre-signed ES256 anon JWT (translation target; internal use only) |
SERVICE_ROLE_KEY_ASYMMETRIC | Pre-signed ES256 service_role JWT (translation target; internal use only) |
DASHBOARD_BASIC_AUTH | Dashboard basic auth credentials (computed in step 1) |
- If all four of
SUPABASE_SECRET_KEY,SUPABASE_PUBLISHABLE_KEY,ANON_KEY_ASYMMETRIC, andSERVICE_ROLE_KEY_ASYMMETRICare set, opaque key translation is enabled. Otherwise, Envoy runs in legacy-only mode and only legacy HS256 keys are accepted. The entrypoint prints which mode is active to the container log at startup.
Configuration changes require restarting the container so the entrypoint re-renders the template:
1docker compose -f docker-compose.yml -f docker-compose.envoy.yml restart api-gwRoutes#
Routes are matched in the order declared. The first matching prefix wins. Protected routes require a valid apikey header; open routes pass through without API key validation.
| Path prefix | Upstream | Path rewrite | Access control | Notes |
|---|---|---|---|---|
/auth/v1/verify | auth | /verify | Open | Email verification |
/auth/v1/callback | auth | /callback | Open | OAuth callback |
/auth/v1/authorize | auth | /authorize | Open | OAuth authorize |
/auth/v1/.well-known/jwks.json | auth | /.well-known/jwks.json | Open | JWKS for third-party verification |
/.well-known/oauth-authorization-server | auth | - | Open | OAuth 2.0 Authorization Server Metadata (RFC 8414) |
/sso/saml/acs | auth | - | Open | SAML assertion consumer |
/sso/saml/metadata | auth | - | Open | SAML metadata |
/functions/v1/ | functions | / | Bypass | Edge Functions runtime performs its own JWT verification; 150s timeout |
/storage/v1/ | storage | / | Bypass | Storage performs its own authorization |
/auth/v1/ | auth | / | API key | Protected Auth endpoints |
/rest/v1/ | rest | / | API key | PostgREST |
/graphql/v1 | rest | /rpc/graphql | API key | pg_graphql (adds Content-Profile: graphql_public) |
/realtime/v1/api | realtime | /api | API key | Realtime REST API |
/realtime/v1/ | realtime | /socket/ | API key | Realtime WebSocket |
/pg/ | meta | / | Service role only | postgres-meta - used by Studio for database access |
/api/mcp | studio | - | Denied | MCP endpoint (blocked by default via RBAC DENY) |
/mcp | studio | /api/mcp | Denied | MCP endpoint (blocked by default via RBAC DENY) |
/ (catch-all) | studio | - | Basic auth | Dashboard; strips inbound Authorization header |
MCP routes are denied at the gateway by default. Allowing local access requires editing the RBAC rule on the /mcp route. See the inline comments in lds.template.yaml for the allowed pattern, and Enabling MCP Server Access for the security model.
Authentication#
The gateway handles three authentication-related steps: dashboard basic auth on the catch-all route, API key enforcement on protected routes, and an opaque-to-internal key translation step that runs before enforcement.
Dashboard basic auth#
The catch-all / route requires HTTP basic auth with credentials from DASHBOARD_USERNAME and DASHBOARD_PASSWORD. The Authorization header is stripped (regardless of scheme) before the request is forwarded to Studio, so the basic auth credentials never reach the upstream service.
API key enforcement on protected routes#
The protected routes (/auth/v1/, /rest/v1/, /graphql/v1, /realtime/v1/api, /realtime/v1/, /pg/) require the apikey header to contain a valid configured key. Acceptable keys are the new opaque sb_publishable_* and sb_secret_* keys (translated to internal JWTs) or the legacy ANON_KEY and SERVICE_ROLE_KEY HS256 JWT API keys.
A Lua filter rejects missing or invalid keys with HTTP 401 Unauthorized. An RBAC filter then applies finer-grained rules:
/pg/- onlyservice_rolekeys (sb_secret_*or legacySERVICE_ROLE_KEY) are allowed (HTTP403otherwise).- All other protected routes - any valid configured key is allowed.
Opaque key translation#
When the new API keys (sb_publishable_*, sb_secret_*) are configured, a chain of Lua filters translates opaque keys into the corresponding pre-signed internal JWTs before the request reaches API key enforcement and upstream services. The entire chain is skipped on /functions/v1/: the Edge Runtime receives the original apikey and Authorization headers unchanged.
The chain operates in this order:
- Query parameter copy. If the request has
?apikey=...but noapikeyheader, the value is copied into theapikeyheader. This normalizes query-only clients (such as Realtime WebSocket connections from browsers, where custom headers are not available). - Query parameter translation. If the
apikeyquery parameter contains an opaque key, it is replaced - both in the URL and in theapikeyheader - with the corresponding pre-signed internal JWT. - Header translation. If the
apikeyheader contains an opaque key, it is replaced with the corresponding pre-signed internal JWT. x-api-keymirror. On the Realtime WebSocket route, theapikeyvalue is copied into thex-api-keyheader (which Realtime reads first for WebSocket authentication).Authorizationsynthesis. If the client did not send a real JWT in theAuthorizationheader (or sent only aBearer sb_*value, which is not a valid JWT), the gateway synthesizesAuthorization: Bearer <apikey>from the (potentially translated)apikeyheader. This is skipped on the Realtime WebSocket route, which usesx-api-keyinstead.
For background on opaque vs asymmetric keys, see New API Keys and Asymmetric Authentication.
Forwarded headers and CORS#
X-Forwarded headers#
Envoy attaches forwarded headers to every upstream request so downstream services can reconstruct the original client-facing URL.
| Header | Value |
|---|---|
X-Forwarded-Host | The client's Host header (added if not already present) |
X-Forwarded-Port | The listener port (8000) |
X-Forwarded-Proto | Set automatically by Envoy based on the connection (http or https) |
X-Forwarded-Prefix | Set per-route to the matched path prefix (for example, /storage/v1 for Storage) |
X-Forwarded-For | Set automatically because use_remote_address: true is enabled on the listener |
X-Forwarded-Prefix is required by Storage for S3 signature v4 verification and for constructing TUS upload Location URLs.
CORS#
The gateway applies a permissive CORS policy at the virtual-host level:
- All origins allowed
- Methods:
GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD, CONNECT, TRACE - All request and response headers allowed
- Preflight max-age: 3600 seconds
This matches both the current Supabase platform behavior and the previous Kong-based gateway. The auth boundary for Supabase APIs is the apikey header rather than the request origin.
If you customize the cors: block in lds.template.yaml to enable allow_credentials: true, you must restrict allow_origin_string_match to specific origins - browsers (and Envoy) reject the combination of credentials with a wildcard origin.
Security hardening#
The gateway is configured with these production-oriented settings:
| Setting | Purpose |
|---|---|
normalize_path: true, merge_slashes: true | Prevents path-confusion bypass of RBAC prefix rules |
path_with_escaped_slashes_action: REJECT_REQUEST | Rejects requests that contain URL-encoded slashes in the path |
use_remote_address: true | Treats Envoy as an edge proxy: uses the peer connection IP as the trusted client address (rather than trusting client-supplied X-Forwarded-For) and strips untrusted x-envoy-* request headers |
headers_with_underscores_action: REJECT_REQUEST | Blocks header smuggling attacks that exploit underscore-vs-hyphen normalization |
per_connection_buffer_limit_bytes: 32768 | Caps per-connection buffer memory to 32 KiB |
max_active_downstream_connections: 30000 | Overload manager limit on total downstream connections |
Admin interface bound to 127.0.0.1:9901 | The admin API is reachable only from inside the container, not from other containers or the host |
Image pinned to envoyproxy/envoy:v1.37.2 (or newer) | Includes published security patches for Envoy 1.37.x |
The Envoy admin interface at 127.0.0.1:9901 exposes /config_dump, which contains the fully rendered configuration including all API keys, JWTs, and the basic auth hash in plaintext. Never expose port 9901 to other containers or to the host.
The gateway listens on plain HTTP only and does not terminate TLS. For public deployments, terminate TLS at an upstream reverse proxy - the Docker setup ships docker-compose.caddy.yml and docker-compose.nginx.yml for this purpose.
Customizing the configuration#
All routing, filter, and cluster changes are made in the YAML files under ./volumes/api/envoy/.
- Adding or modifying routes. Edit
lds.template.yaml. Routes are ordered - place new routes before the catch-all/route to ensure they match. Keep per-routebasic_auth: disabledfor API routes and set an appropriate RBAC override if the route should bypass the global policy. - Adding a new upstream service. Add a cluster definition to
cds.yamlwith the service's DNS name and port, then reference it from a route'scluster:field. - Adding a new environment variable. Placeholders in the template use the
${VAR_NAME}form. If you add a placeholder, update bothdocker-compose.envoy.yml(to pass the variable into the container) anddocker-entrypoint.sh(to substitute it withsed). - Applying changes. Envoy reads the rendered
lds.yamlfrom the filesystem. Configuration changes require restarting the container so the entrypoint re-renders the template:
1docker compose -f docker-compose.yml -f docker-compose.envoy.yml restart api-gwThis guide does not cover Envoy's route, filter, and cluster reference. See the Envoy documentation for the full configuration surface.
Admin interface#
Envoy exposes an admin interface on 127.0.0.1:9901 inside the container, with endpoints like /ready, /clusters, /stats, and /config_dump. The standard envoyproxy/envoy image is minimal (no curl or wget), so the admin endpoints are not reachable via docker exec without adding tooling.
The simplest way to query the admin interface during debugging is to run a short-lived curl container that joins the same network namespace as supabase-envoy:
1docker run --rm --network container:supabase-envoy curlimages/curl http://127.0.0.1:9901/clusters1docker run --rm --network container:supabase-envoy curlimages/curl http://127.0.0.1:9901/stats1docker run --rm --network container:supabase-envoy curlimages/curl http://127.0.0.1:9901/config_dump/config_dump includes secrets in plaintext. Never expose port 9901 to a public network or untrusted host, and never share its output without first removing secrets.
Troubleshooting#
Logs#
Envoy writes access logs and filter output to stdout. View them with:
1docker compose -f docker-compose.yml -f docker-compose.envoy.yml logs api-gwThe access log format is a standard combined log with the request method, original path, response code, and bytes sent.
Common issues#
401 Unauthorizedon a protected route. Theapikeyheader is missing or does not match any configured key. Verify that the header value exactly matches one ofANON_KEY,SERVICE_ROLE_KEY,SUPABASE_PUBLISHABLE_KEY, orSUPABASE_SECRET_KEYin your.envfile. Note thatSUPABASE_PUBLISHABLE_KEYandSUPABASE_SECRET_KEYare only accepted when the new key configuration is fully set up - see New API Keys and Asymmetric Authentication.403 Forbiddenon/pg/. The/pg/route requires a service_role key (SUPABASE_SECRET_KEYor legacySERVICE_ROLE_KEY). Anon and publishable keys are rejected.403 Forbiddenon/api/mcpor/mcp. These routes are blocked by default. See Enabling MCP Server Access.SignatureDoesNotMatchon S3 requests to Storage. Verify that the Storage service configuration indocker-compose.ymlcontainsREQUEST_ALLOW_X_FORWARDED_PATH=trueandSTORAGE_PUBLIC_URL. Storage uses theX-Forwarded-Prefixheader the gateway sends to reconstruct the original request path for SigV4 verification.400 Bad Requestwith underscore headers.headers_with_underscores_action: REJECT_REQUESTis enabled. Some clients send headers likeX_Forwarded_Forwith underscores; these are rejected. Use hyphens in header names.
See also#
- New API Keys and Asymmetric Authentication - Background on opaque keys and asymmetric JWTs
- Configure Reverse Proxy and HTTPS - Caddy or Nginx in front of the gateway
- Envoy documentation - Filter, route, and cluster reference