Self-Hosting

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#

Enabling the Envoy gateway#

The Envoy gateway is provided as a Docker Compose override.

Start your self-hosted Supabase stack with both the base compose file and the Envoy override:

1
docker compose -f docker-compose.yml -f docker-compose.envoy.yml up -d

The 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:

1
curl -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:

1
Client
2
3
4
Listener (port 8000)
5
6
7
HTTP filter chain
8
├─ CORS
9
├─ Basic Auth (dashboard only)
10
├─ Lua: copy ?apikey query to header
11
├─ Lua: translate opaque keys in query
12
├─ Lua: translate opaque keys in header
13
├─ Lua: mirror apikey to x-api-key (Realtime WS)
14
├─ Lua: synthesize Authorization header
15
├─ Lua: 401 for missing/invalid API key
16
├─ RBAC (global: service_role → /pg/, apikey → other API routes;
17
│ per-route DENY override on /mcp)
18
└─ Router
19
20
21
Upstream clusters
22
├─ 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):

FilePurpose
envoy.yamlBootstrap configuration. Points Envoy at the CDS and LDS files, configures the admin interface, and sets an overload manager limit on downstream connections.
cds.yamlCluster Discovery Service - upstream service definitions (DNS, ports, health checks, connect timeouts, circuit breakers).
lds.template.yamlListener Discovery Service template. Defines the listener, filter chain, routes, RBAC policy, and CORS policy. Contains placeholders for keys and credentials.
docker-entrypoint.shRenders 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:

  1. A SHA1+base64 hash of DASHBOARD_PASSWORD is computed and joined with DASHBOARD_USERNAME into a single DASHBOARD_BASIC_AUTH string in the username:{SHA}<base64-encoded-sha1> format that Envoy's basic_auth filter expects. Other hash formats are not supported.
  2. The following variables are then substituted into lds.template.yaml and the result is written to lds.yaml:
VariableUsed for
ANON_KEYLegacy HS256 anon JWT (API key validation, RBAC)
SERVICE_ROLE_KEYLegacy HS256 service_role JWT (API key validation, RBAC)
SUPABASE_PUBLISHABLE_KEYOpaque sb_publishable_* key (translation source)
SUPABASE_SECRET_KEYOpaque sb_secret_* key (translation source)
ANON_KEY_ASYMMETRICPre-signed ES256 anon JWT (translation target; internal use only)
SERVICE_ROLE_KEY_ASYMMETRICPre-signed ES256 service_role JWT (translation target; internal use only)
DASHBOARD_BASIC_AUTHDashboard basic auth credentials (computed in step 1)
  1. If all four of SUPABASE_SECRET_KEY, SUPABASE_PUBLISHABLE_KEY, ANON_KEY_ASYMMETRIC, and SERVICE_ROLE_KEY_ASYMMETRIC are 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.

Routes#

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 prefixUpstreamPath rewriteAccess controlNotes
/auth/v1/verifyauth/verifyOpenEmail verification
/auth/v1/callbackauth/callbackOpenOAuth callback
/auth/v1/authorizeauth/authorizeOpenOAuth authorize
/auth/v1/.well-known/jwks.jsonauth/.well-known/jwks.jsonOpenJWKS for third-party verification
/.well-known/oauth-authorization-serverauth-OpenOAuth 2.0 Authorization Server Metadata (RFC 8414)
/sso/saml/acsauth-OpenSAML assertion consumer
/sso/saml/metadataauth-OpenSAML metadata
/functions/v1/functions/BypassEdge Functions runtime performs its own JWT verification; 150s timeout
/storage/v1/storage/BypassStorage performs its own authorization
/auth/v1/auth/API keyProtected Auth endpoints
/rest/v1/rest/API keyPostgREST
/graphql/v1rest/rpc/graphqlAPI keypg_graphql (adds Content-Profile: graphql_public)
/realtime/v1/apirealtime/apiAPI keyRealtime REST API
/realtime/v1/realtime/socket/API keyRealtime WebSocket
/pg/meta/Service role onlypostgres-meta - used by Studio for database access
/api/mcpstudio-DeniedMCP endpoint (blocked by default via RBAC DENY)
/mcpstudio/api/mcpDeniedMCP endpoint (blocked by default via RBAC DENY)
/ (catch-all)studio-Basic authDashboard; strips inbound Authorization header

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/ - only service_role keys (sb_secret_* or legacy SERVICE_ROLE_KEY) are allowed (HTTP 403 otherwise).
  • 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:

  1. Query parameter copy. If the request has ?apikey=... but no apikey header, the value is copied into the apikey header. This normalizes query-only clients (such as Realtime WebSocket connections from browsers, where custom headers are not available).
  2. Query parameter translation. If the apikey query parameter contains an opaque key, it is replaced - both in the URL and in the apikey header - with the corresponding pre-signed internal JWT.
  3. Header translation. If the apikey header contains an opaque key, it is replaced with the corresponding pre-signed internal JWT.
  4. x-api-key mirror. On the Realtime WebSocket route, the apikey value is copied into the x-api-key header (which Realtime reads first for WebSocket authentication).
  5. Authorization synthesis. If the client did not send a real JWT in the Authorization header (or sent only a Bearer sb_* value, which is not a valid JWT), the gateway synthesizes Authorization: Bearer <apikey> from the (potentially translated) apikey header. This is skipped on the Realtime WebSocket route, which uses x-api-key instead.

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.

HeaderValue
X-Forwarded-HostThe client's Host header (added if not already present)
X-Forwarded-PortThe listener port (8000)
X-Forwarded-ProtoSet automatically by Envoy based on the connection (http or https)
X-Forwarded-PrefixSet per-route to the matched path prefix (for example, /storage/v1 for Storage)
X-Forwarded-ForSet automatically because use_remote_address: true is enabled on the listener

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.

Security hardening#

The gateway is configured with these production-oriented settings:

SettingPurpose
normalize_path: true, merge_slashes: truePrevents path-confusion bypass of RBAC prefix rules
path_with_escaped_slashes_action: REJECT_REQUESTRejects requests that contain URL-encoded slashes in the path
use_remote_address: trueTreats 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_REQUESTBlocks header smuggling attacks that exploit underscore-vs-hyphen normalization
per_connection_buffer_limit_bytes: 32768Caps per-connection buffer memory to 32 KiB
max_active_downstream_connections: 30000Overload manager limit on total downstream connections
Admin interface bound to 127.0.0.1:9901The 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

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-route basic_auth: disabled for 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.yaml with the service's DNS name and port, then reference it from a route's cluster: field.
  • Adding a new environment variable. Placeholders in the template use the ${VAR_NAME} form. If you add a placeholder, update both docker-compose.envoy.yml (to pass the variable into the container) and docker-entrypoint.sh (to substitute it with sed).
  • Applying changes. Envoy reads the rendered lds.yaml from the filesystem. Configuration changes require restarting the container so the entrypoint re-renders the template:
1
docker compose -f docker-compose.yml -f docker-compose.envoy.yml restart api-gw

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:

1
docker run --rm --network container:supabase-envoy curlimages/curl http://127.0.0.1:9901/clusters
1
docker run --rm --network container:supabase-envoy curlimages/curl http://127.0.0.1:9901/stats
1
docker run --rm --network container:supabase-envoy curlimages/curl http://127.0.0.1:9901/config_dump

Troubleshooting#

Logs#

Envoy writes access logs and filter output to stdout. View them with:

1
docker compose -f docker-compose.yml -f docker-compose.envoy.yml logs api-gw

The access log format is a standard combined log with the request method, original path, response code, and bytes sent.

Common issues#

  • 401 Unauthorized on a protected route. The apikey header is missing or does not match any configured key. Verify that the header value exactly matches one of ANON_KEY, SERVICE_ROLE_KEY, SUPABASE_PUBLISHABLE_KEY, or SUPABASE_SECRET_KEY in your .env file. Note that SUPABASE_PUBLISHABLE_KEY and SUPABASE_SECRET_KEY are only accepted when the new key configuration is fully set up - see New API Keys and Asymmetric Authentication.
  • 403 Forbidden on /pg/. The /pg/ route requires a service_role key (SUPABASE_SECRET_KEY or legacy SERVICE_ROLE_KEY). Anon and publishable keys are rejected.
  • 403 Forbidden on /api/mcp or /mcp. These routes are blocked by default. See Enabling MCP Server Access.
  • SignatureDoesNotMatch on S3 requests to Storage. Verify that the Storage service configuration in docker-compose.yml contains REQUEST_ALLOW_X_FORWARDED_PATH=true and STORAGE_PUBLIC_URL. Storage uses the X-Forwarded-Prefix header the gateway sends to reconstruct the original request path for SigV4 verification.
  • 400 Bad Request with underscore headers. headers_with_underscores_action: REJECT_REQUEST is enabled. Some clients send headers like X_Forwarded_For with underscores; these are rejected. Use hyphens in header names.

See also#