Skip to content

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!
}
# Included inside the generated location /_src/orders/ (any proxy_* directive).
# The orders service logs the address of the end client, not the gateway's.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
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"].

location /_src/users/ {
    # ...
    proxy_pass_request_headers off;
    proxy_set_header Authorization $http_authorization;
    proxy_set_header Accept-Language $http_accept_language;
    proxy_set_header X-Request-Id $http_x_request_id;
    proxy_set_header Accept "application/json";
    # ...
}

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.

location /_src/catalog/ {
    # ...
    proxy_set_header Cookie "";
    proxy_set_header Authorization "";
    proxy_set_header Accept "application/json";
    # ...
}

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:

sources.d/orders/mtls.conf
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.