Auth context forwarding¶
The gateway does not authenticate anyone. It forwards the caller's context to the services, which decide, and it keeps per-user responses apart in the cache. This example has three services with three header policies.
flowchart LR
c(["client<br/>Authorization: Bearer abc<br/>Accept-Language: fr<br/>X-Request-Id: r-42<br/>Cookie: session=…"]) --> gw{{gqlgw}}
gw -- "allowlist<br/>Authorization · Accept-Language · X-Request-Id" --> users[(users)]
gw -- "allowlist + service key<br/>Authorization · X-Request-Id<br/>X-Api-Key: from the environment<br/>X-Forwarded-For (sources.d)" --> orders[(orders)]
gw -- "denylist<br/>everything but Cookie and Authorization" --> catalog[(catalog)]
# Forwarding the caller's context (identity, language, trace id) to the backends.
#
# gqlc compile -c gqlgw.toml schema.graphqls
# gqlc render -c gqlgw.toml -o /etc/nginx/gqlgw
[cache]
# @cache(scope: "PRIVATE") adds a hash of this header to the cache key
private_header = "Authorization"
# Allowlist: only these client headers reach the users service.
[sources.users]
url = "${USERS_URL:-http://users.internal:8080}"
forward_headers = ["Authorization", "Accept-Language", "X-Request-Id"]
# The orders service authenticates the gateway too: a service key from the
# environment, added to every request (the client cannot send or override it).
[sources.orders]
url = "${ORDERS_URL:-https://orders.internal}"
forward_headers = ["Authorization", "X-Request-Id"]
set_headers = { X-Api-Key = "${ORDERS_API_KEY:?set ORDERS_API_KEY to the orders service key}" }
# sources.d/orders/*.conf adds what gqlgw.toml cannot express (see that directory)
# Denylist: every client header except these (a legacy service that reads
# custom headers set by the clients).
[sources.catalog]
url = "${CATALOG_URL:-http://catalog.internal}"
drop_headers = ["Cookie", "Authorization"]
"""
Example schema: the caller's identity travels from the client to the backends.
The services check the Authorization header themselves; the gateway only
forwards it (gqlgw.toml) and keeps per-user responses apart in the cache.
"""
type Query {
"The caller, as seen by the users service (GET /me with the client's token)."
me: User @fetch(source: "users", path: "/me") @cache(ttl: 30, scope: "PRIVATE")
"Any user: the same response for every caller, so a shared cache entry."
user(id: ID!): User @fetch(source: "users", path: "/users/{args.id}")
}
type User @cache(ttl: 300) {
id: ID!
name: String!
"Orders are private: one cache entry per Authorization value."
orders: [Order!]!
@fetch(source: "orders", path: "/users/{parent.id}/orders")
@cache(ttl: 10, scope: "PRIVATE")
}
type Order {
id: ID!
total: Float!
items: [Product!]! @fetch(source: "catalog", path: "/orders/{parent.id}/products")
}
type Product @cache(ttl: 600) {
id: ID!
name: String!
}
gqlc compile -c gqlgw.toml schema.graphqls
ORDERS_API_KEY=… gqlc render -c gqlgw.toml -o /etc/nginx/gqlgw
Three header policies¶
forward_headers lists the client headers that reach the service. Nothing else does: no
cookies, no internal headers, no X-Forwarded-* unless you add them. The default is
["Authorization"].
set_headers adds fixed headers, after the forwarded ones: a client cannot override them.
Values are literal (a $ stays a $) and can come from the environment at render time.
location /_src/orders/ {
# ...
proxy_pass_request_headers off;
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Request-Id $http_x_request_id;
proxy_set_header Accept "application/json";
proxy_set_header X-Api-Key "…value of ORDERS_API_KEY…";
# ...
include "/etc/gqlgw/sources.d/orders/*.conf"; # X-Forwarded-For, X-Real-IP
proxy_pass "https://gqlgw_orders$gqlgw_path";
}
drop_headers forwards every client header except the listed ones. Use it for services that
read many custom headers. It is exclusive with forward_headers.
Some headers are managed by the gateway and cannot be listed: Host, Connection,
Content-Length, Content-Type, Transfer-Encoding, Accept-Encoding, TE, Keep-Alive,
Upgrade, Expect, Proxy-Connection. Accept defaults to application/json unless you
forward it or set it.
Per-user responses and the cache¶
The cache key is the upstream request. GET /me is the same request for every caller, so it
must not be cached as a shared resource. scope: "PRIVATE" adds a hash of the private_header
value (default Authorization) to the key:
flowchart LR
a["alice: Authorization: Bearer A"] --> ka["gqlgw:0:users:/me|md5(Bearer A)"]
b["bob: Authorization: Bearer B"] --> kb["gqlgw:0:users:/me|md5(Bearer B)"]
a2["alice or bob: user(id: 7)"] --> k7["gqlgw:0:users:/users/7 (shared)"]
| Field | Cache | Why |
|---|---|---|
Query.me |
@cache(ttl: 30, scope: "PRIVATE") |
the response depends on the caller |
Query.user |
User @cache(ttl: 300), shared |
the same user for everyone |
User.orders |
PRIVATE, 10 s |
a user's orders, visible only with their token |
Order.items |
none on the field, Product @cache(ttl: 600) |
the catalog is public |
A shared entry is served to every caller
If a service filters its answer by caller (per-user fields, permissions, tenants), the route
must be PRIVATE, or not cached at all. When in doubt, leave @cache out.
Adding what gqlgw.toml cannot express¶
Files in sources.d/<source>/*.conf, next to gqlgw.toml, are included inside the location of
that source. Any proxy_* directive works. For instance, client certificates for an mTLS
service:
proxy_ssl_certificate /etc/gqlgw/certs/gateway.pem;
proxy_ssl_certificate_key /etc/gqlgw/certs/gateway.key;
In the Docker image, the directory is /etc/gqlgw/sources.d/ (COPY sources.d/ /etc/gqlgw/sources.d/).
Checking what a service receives¶
Point a source at an echo endpoint and look at the request. The test suite does exactly this
(t/006-config.t): with the allowlist, the backend receives Authorization and nothing else
from the client; with the denylist, it receives everything but the listed headers.