gqlgate has no authentication or authorization of its own. It forwards headers and lets the upstream decide. That makes the header policy the whole of its security surface.
pdbq's trusted-gateway auth mode (rls.auth.mode: headers) believes any
X-Pdbq-Claim-* header it receives. Its own documentation says that mode
belongs strictly behind a gateway that strips inbound claim headers.
gqlgate is that gateway. Every inbound header matching upstream.claim_prefix
is dropped before the upstream request is built, unconditionally, with no
configuration to disable it. If it were overridable, any client could send
X-Pdbq-Claim-Role: postgres
and assume a superuser role. The first stripped header per process is logged at warn level so a misconfigured client is visible without flooding the log.
This applies whatever the upstream's auth mode is: gqlgate cannot know whether the target trusts those headers, so it never forwards them.
Inbound → upstream is a denylist. gqlgate is a transparent pipe; an allowlist would break every custom auth scheme, tracing header and tenant header a deployment has. Dropped:
- Hop-by-hop headers per RFC 7230 (
Connection,Keep-Alive,Proxy-*,TE,Trailer,Transfer-Encoding,Upgrade), plus anything named in the inboundConnectionheader. - Headers gqlgate sets itself:
Content-Type,Content-Length,Content-Encoding,Accept,Accept-Encoding,Host. - Anything in
upstream.strip_headers. - Anything matching
upstream.claim_prefix, as above.
Everything else — including Authorization — is forwarded untouched.
Upstream → client is an allowlist. Only headers named in
upstream.forward_response_headers are copied back. An unexpected upstream
should not be able to set arbitrary headers on gqlgate's response.
Set-Cookie is refused even when explicitly allowlisted: forwarding cookies
from an upstream is a session-fixation vector.
gqlgate appends the client IP to X-Forwarded-For and sets
X-Forwarded-Proto and X-Forwarded-Host. pdbq's rls.auth.trusted_proxies
check inspects the direct peer, which is gqlgate, so configure it with
gqlgate's address:
# pdbq.yaml
rls:
auth:
mode: headers
trusted_proxies: ["10.0.0.0/8"] # the network gqlgate runs onupstream.inject_claims lets gqlgate supply claims itself, for deployments
where it terminates authentication:
upstream:
inject_claims:
Role: "app_user"
UserId: '{{.Header "X-Authenticated-User"}}'Injected claims are unauthenticated trust: whoever can reach gqlgate gets whatever role is named. Accordingly:
- It is off by default.
- Templates may only read inbound request headers. There is no arbitrary logic.
- Startup refuses a configuration that injects claims toward a
non-loopback, non-private upstream unless
upstream.allow_insecure_claimsis set. Injected claims must not cross a public network. - A loud warning is logged at boot whenever injection is enabled.
- Injection runs after stripping, so an injected claim can never be overridden by a client-supplied header of the same name.
Only enable this when the upstream is on a private network and gqlgate is the only thing that can reach it.
By default, upstream error messages, their path, and their extensions
(including PostgreSQL SQLSTATEs and constraint names) are passed through. That
is useful in development and for well-behaved API clients, but it does reveal
schema details.
For a public deployment, redact:
errors:
include_extensions: false
include_path: falseConsider also setting pdbq's errors.detail: strict, which stops constraint
and column names leaving the database layer at all.
Error locations are always dropped: they refer to the document gqlgate
generated, which the client never saw and cannot act on.
/openapi.json describes every route being served, including column names and
enum values. That is the same information gqlgate routes prints and that any
client of the API can infer, so it is served by default — but it does hand an
unauthenticated caller a map of the schema.
For a deployment where the API's shape should not be public:
server:
openapi:
enabled: false # or keep it and restrict access at the proxy
allow_any_origin: false # stops a docs UI on another origin loading itallow_any_origin relaxes CORS for the document alone. The data endpoints
always obey server.cors_origins, so turning it on does not expose data — only
the description.
The bundled Scalar UI is a development convenience. It is not
authenticated, and docker compose up publishes it. Do not run that stack
as-is in production.
- The upstream is not reachable directly from clients — only through gqlgate. Otherwise the claim stripping is bypassed.
-
rls.enabled: trueon pdbq, with roles and policies configured. -
upstream.inject_claimsis empty unless you deliberately want gqlgate to assert identity, and the upstream is on a private network. -
errors.include_extensionsisfalsefor a public API. -
routes.allow_unfiltered_bulkisfalse(the default), soDELETE /userswith an empty filter cannot empty a table. -
server.cors_originslists explicit origins rather than*if browsers call the API with credentials. -
gqlgate routesreviewed, so you know exactly what is exposed. Useroutes.ignoreto withhold fields that should not be public. -
server.openapi.enabledis a deliberate choice, and the bundled Scalar UI container is not running in production.