Configuration¶
serve.LoadConfig() reads every one of the settings below from the
environment — never from a file — so that credentials never end up sitting
on disk next to the binary. It fails to start rather than come up with a
gap: a missing public base URL or allowlist is an error, not a silent
default.
One setting on this page is the exception to "everything comes from the
environment": the OIDC key-refresh interval, covered under
Identity, is reachable only by calling identity.NewOIDC
directly with your own identity.OIDCConfig — bypassing serve.LoadConfig
and serve.New — not through any GANGWAY_* variable.
Error messages name the offending variable but never its value. Several of these variables are not meant to be echoed back — allowlist prefixes, writer claim values — and startup failures end up in logs.
Required¶
| Variable | Purpose | Example |
|---|---|---|
GANGWAY_PUBLIC_BASE_URL |
The server's own externally reachable base URL. Sent back in the WWW-Authenticate challenge so a connector knows where to discover OAuth metadata. Never derived from request headers — that would let a caller redirect the sign-in flow to a server of its own choosing. |
https://mcp.example.com |
Required for the default OIDC verifier — not by WithVerifier¶
| Variable | Purpose | Example |
|---|---|---|
GANGWAY_ISSUER_URL |
The OpenID Connect issuer, without the /.well-known/... suffix. serve.New fetches its discovery document at startup and refuses to start if it cannot be reached. |
https://login.microsoftonline.com/<tenant-id>/v2.0 |
GANGWAY_AUDIENCE |
The value the token's aud claim must contain. |
api://your-app-id |
Both are read by serve.LoadConfig, but not required by it — the
requirement lives in serve.New's default branch instead, exactly where
the values actually get used (building the OIDC verifier via
identity.NewOIDC). A caller who calls serve.New without
WithVerifier and leaves either empty still fails to start, naming the
GANGWAY_* variable exactly as LoadConfig used to — just from New,
not from LoadConfig. A caller who uses WithVerifier is not forced to
set either.
Scope advertisement¶
| Variable | Purpose | Default |
|---|---|---|
GANGWAY_REQUIRED_SCOPES |
Comma-separated OAuth 2.0 scope values a connector should request when signing in against GANGWAY_ISSUER_URL. |
empty |
Leaving this unset changes nothing: no server built before this variable existed advertised a scope, and none built without setting it does now.
Set, it is advertised through both discovery paths RFC 9728 recognises — a connector following either one learns the same list:
- The
WWW-Authenticatechallenge a bare, unauthenticated request to/mcpreceives carries ascopeauth-param (RFC 6750 §3): every configured value, space-delimited, inside one quoted string —Bearer scope="mcp.access", resource_metadata="...", not onescopeparameter per value. - The RFC 9728 protected-resource metadata document — reachable at
/.well-known/oauth-protected-resourceeven before any request has been made, for a connector that probes for it up front — carries the same list underscopes_supported.
Without either, a connector has no way to learn which scope this server
needs. It falls back to whatever it asks for by default — often just
openid profile offline_access — the sign-in flow completes normally,
and the mismatch only surfaces at the first tool call, as a token
rejected for the wrong audience: a failure mode that looks like a broken
server, not a missing scope.
Each entry must be a single RFC 6749 §3.3 scope-token — in practice,
anything without a literal space, double quote, or backslash in it.
LoadConfig rejects anything else at startup, naming the offending
variable and the position of the bad entry, never the entry's own text.
A space would silently turn one configured scope into two once a
connector splits the challenge's scope parameter back apart by
whitespace, as the wire format requires; a double quote would instead
terminate that parameter's quoted string early and corrupt the header for
every caller.
GANGWAY_ADVERTISED_SCOPES: when what you request isn't what you get back¶
| Variable | Purpose | Default |
|---|---|---|
GANGWAY_ADVERTISED_SCOPES |
Comma-separated OAuth 2.0 scope values to advertise instead of GANGWAY_REQUIRED_SCOPES, in both discovery paths above. Same syntax and validation as GANGWAY_REQUIRED_SCOPES. |
empty — falls back to GANGWAY_REQUIRED_SCOPES |
Neither RFC 9728 nor RFC 6749/6750 says whether a scope must be advertised
under its bare name or some fully qualified form — that choice belongs to
the authorization server. Most providers this project has been checked
against (Authentik, Keycloak, Auth0, Okta, and Cloudflare's own OAuth
provider) use the same short name both when a connector requests it and
in the token's scp claim afterwards, so GANGWAY_REQUIRED_SCOPES alone
covers them.
Microsoft Entra ID does not. Requesting a bare scope name there fails
at sign-in with AADSTS650053 ("the application asked for scope
'mcp.access' that doesn't exist on the resource 'Microsoft Graph'") — a
name with no resource prefix resolves against the Microsoft Graph default
resource, not your own application. What Entra requires on the
request/advertisement side is the fully qualified
api://<Application-ID-URI>/<scope-name> form (see Azure App Service's
WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES
setting
for an example of the same shape), while the token's scp claim still
carries only the short name afterwards.
Set both variables for an Entra deployment — GANGWAY_REQUIRED_SCOPES to
the short name, GANGWAY_ADVERTISED_SCOPES to the fully qualified
api:// form — and gangway advertises the qualified form while nothing
else changes:
GANGWAY_REQUIRED_SCOPES=mcp.access
GANGWAY_ADVERTISED_SCOPES=api://11111111-2222-3333-4444-555555555555/mcp.access
Leaving GANGWAY_ADVERTISED_SCOPES unset — the default, and every
deployment's setting before this variable existed — changes nothing:
both discovery paths fall back to advertising GANGWAY_REQUIRED_SCOPES,
exactly as they did before this variable existed.
The allowlist: one of these two, at least¶
| Variable | Purpose | Example |
|---|---|---|
GANGWAY_ALLOWED_PREFIXES |
Comma-separated CIDR prefixes. Fixed and read once at startup — use it for a provider's stable range or your own infrastructure. | 203.0.113.0/24,2001:db8::/32 |
GANGWAY_REMOTE_LIST_URL |
A URL fetched at startup and re-fetched on GANGWAY_REMOTE_LIST_INTERVAL, for a provider whose published range changes — the shipped parser reads the format OpenAI publishes for its connector IPs. A failed initial fetch stops the server from starting; a later failed refresh keeps the last good list rather than locking out every caller over a transient outage. |
https://example.com/published-ips.json |
At least one of the two is required. Both may be set — the effective allowlist is their union. Refusing to start with neither configured is deliberate: without a filter, every caller would be let in.
If GANGWAY_REMOTE_LIST_URL itself carries a credential — a signed URL,
a token in the query string — a failed fetch will not echo it back:
every error message this fetch can produce names only the scheme and
host, never the path or query.
GANGWAY_REMOTE_LIST_INTERVAL (optional, default 1h, minimum 1m) sets
the refresh interval for the remote list. The one-minute floor exists
because this value comes from the environment — unchecked input — and an
accidentally tiny interval would hammer whoever publishes the list.
Client address and trusted proxies¶
These two matter together, and choosing the wrong header is the single easiest way to defeat the allowlist above without anything failing loudly. Read Behind a proxy before setting either of them in front of a real proxy.
| Variable | Purpose | Default |
|---|---|---|
GANGWAY_CLIENT_IP_HEADER |
Which single forwarding header is trusted: x-forwarded-for, x-real-ip, or cf-connecting-ip. Exactly one is evaluated — trying several in turn would let a caller pick whichever one the server happens to prefer. |
x-forwarded-for |
GANGWAY_TRUSTED_PROXIES |
Comma-separated CIDR prefixes of the proxies whose forwarding header is believed. Leave empty when nothing sits in front of Gangway — then only the TCP peer address counts, and the header (whatever it says) is ignored entirely. | empty |
Identity¶
| Variable | Purpose | Default |
|---|---|---|
GANGWAY_SUBJECT_CLAIM |
The claim used as the caller's stable identifier. sub works for most providers; Entra ID pseudonymizes it per application, so oid is the tenant-stable choice there — see Microsoft Entra ID. |
sub |
Key rotation¶
identity.NewOIDC — what serve.New calls internally to build the
verifier — refetches the issuer's discovery document and signing keys on a
background timer, defaulting to every 15 minutes, and swaps in a
verifier built from whatever it just fetched, dropping any key the
previous verifier trusted that is no longer being published.
This is the practical answer to a stolen signing key: rotating the key at the identity provider does not immediately invalidate tokens signed with the old one everywhere — it invalidates them here only once the next background refresh has run. Between the rotation and that refresh, a token forged with the compromised key would still verify. With the default interval, that window is at most 15 minutes; it does not shrink by rotating faster at the provider, only by refreshing faster here.
There is currently no GANGWAY_* variable for this interval. It is
set through identity.OIDCConfig.KeyRefreshInterval, a field on the
identity package's own config type — reachable only by calling
identity.NewOIDC yourself instead of going through serve.LoadConfig
and serve.New. If you need a shorter window than 15 minutes, that is
the path; there is no Option on serve.Server for it today.
Replacing the verifier entirely: WithVerifier¶
Everything above this point assumes callers present an OIDC bearer token,
verified against GANGWAY_ISSUER_URL's discovery document. WithVerifier
replaces that assumption outright, for a caller that is not recognised by
OIDC at all — a static token, an opaque token looked up in your own
database, a second identity provider Gangway has no built-in support for.
Anything that implements identity.Verifier works:
Once this option is used, serve.New never calls identity.NewOIDC and
never touches GANGWAY_ISSUER_URL, GANGWAY_AUDIENCE, or
GANGWAY_SUBJECT_CLAIM — none of the three need to be set. Everything
downstream of authentication is unaffected: the identity your verifier
returns is exactly what serve.IdentityFrom(ctx) hands to tool handlers
and what the GANGWAY_WRITERS_* grid (or a replaced access.Decider)
checks — so a hand-rolled verifier still needs to populate
Identity.Claims with whatever your writer-claim check expects to find
there, the same as an OIDC token's claims would.
Omitting WithVerifier keeps the default OIDC verifier exactly as
before — adding this option changes nothing for a server that never
calls it. Calling it with nil is a different, always-mistaken thing:
serve.New refuses it at construction time with an error naming
WithVerifier, rather than silently treating a nil verifier as "no
option was given" and falling back to OIDC, or, worse, ending up with a
server that answers requests while never checking anyone. That refusal
also catches a typed nil — a nil pointer, map, or similar value that
happens to implement identity.Verifier — not just the bare nil
literal, since Go considers those != nil at the interface level even
though calling Verify on one would panic.
Gangway trusts your Verifier completely
This is the most security-sensitive extension point in the package —
using it redefines what "authenticated" means for this server.
Gangway does not, and cannot, second-guess what Verify returns:
every *identity.Identity it hands back is treated as genuine by
everything downstream — serve.IdentityFrom, the tool-authorization
middleware, the GANGWAY_WRITERS_* grid. A Verify that accepts more
than it should, or that mishandles its own rejection path and returns
an Identity instead of an error, grants exactly that access, with
nothing in Gangway to catch it. New only guarantees that some
non-nil Verifier is in place and actually gets called — never the
correctness of what it decides. Writing a correct Verifier is on
you.
A static bearer token, for a server with exactly one caller
package main
import (
"context"
"fmt"
"log"
"github.com/strausmann/gangway/identity"
"github.com/strausmann/gangway/serve"
)
// staticTokenVerifier accepts exactly one bearer token and reports
// back a fixed identity -- useful for a caller Gangway does not need
// to distinguish from any other, such as a single backend service
// calling on its own behalf.
type staticTokenVerifier struct {
token string
id *identity.Identity
}
func (v staticTokenVerifier) Verify(_ context.Context, rawToken string) (*identity.Identity, error) {
if rawToken != v.token {
// Wraps identity.ErrUnauthenticated, exactly as the built-in
// OIDC verifier's own failures do: a caller of Verify -- code
// outside this package, not just Gangway's own authenticate
// layer -- can then tell "this token was rejected" apart from
// some other failure (a database a real verifier looks a token
// up against being unreachable, say) via errors.Is. An error
// that does not wrap it breaks that contract silently: nothing
// here would fail to compile or even to run, it would just
// make every failure of this Verifier indistinguishable from
// a rejected token to any caller relying on errors.Is.
return nil, fmt.Errorf("%w: unrecognised token", identity.ErrUnauthenticated)
}
return v.id, nil
}
func main() {
ctx := context.Background()
// GANGWAY_ISSUER_URL and GANGWAY_AUDIENCE do not need to be set:
// LoadConfig no longer requires either (see Required for the
// default OIDC verifier above), and WithVerifier below means New
// never reads them anyway. GANGWAY_PUBLIC_BASE_URL and an allowlist
// (GANGWAY_ALLOWED_PREFIXES or GANGWAY_REMOTE_LIST_URL) are still
// required.
cfg, err := serve.LoadConfig()
if err != nil {
log.Fatal(err)
}
verifier := staticTokenVerifier{
token: "REPLACE_ME_WITH_A_LONG_RANDOM_TOKEN",
id: &identity.Identity{
Subject: "backend-service",
Claims: map[string]any{"sub": "backend-service"},
},
}
gw, err := serve.New(ctx, cfg, serve.WithVerifier(verifier))
if err != nil {
log.Fatal(err)
}
// gw.AttachMCP(...) / gw.Run(ctx) as in Getting started.
_ = gw
}
Authorization: who may call a writing tool¶
Reading tools (access.KindRead, assigned via serve.WithToolKinds) are
open to any authenticated caller, regardless of these three. They govern
writing tools only.
| Variable | Purpose | Default |
|---|---|---|
GANGWAY_WRITERS_CLAIM |
The claim that carries roles, e.g. roles or groups. Accepts either shape a provider might send: a single string or a list. |
empty |
GANGWAY_WRITERS_VALUE |
The value that grants writing, checked against GANGWAY_WRITERS_CLAIM. |
empty |
GANGWAY_ALLOW_WRITE_BY_DEFAULT |
true permits writing for any authenticated caller, skipping the claim check entirely. Use only where every caller is trusted equally — a single-tenant deployment with no untrusted callers, for example. |
false |
Leaving GANGWAY_WRITERS_CLAIM or GANGWAY_WRITERS_VALUE empty while
GANGWAY_ALLOW_WRITE_BY_DEFAULT is unset does not open writing to everyone
— it does the opposite. A forgotten setting must not open the server, so
every writing call is refused until both are configured or the default is
explicitly enabled.
Replacing the decision entirely¶
The read/write claim check above is access.NewGrid, the default
decider serve.New builds from the three settings in this section. It
can be replaced outright with serve.WithDecider(...). The one
alternative Gangway ships is access.AllowAll(), an exported Decider
that permits every authenticated caller to call every tool —
reading or writing, no claim check at all:
This is not a relaxed default to reach for casually — it collapses the
entire read/write distinction this section is about. It only makes sense
for a server whose tools are all equally harmless to any authenticated
caller (every tool reads, say, or the deployment has exactly one trusted
caller). For anything else, the three GANGWAY_WRITERS_* settings above
are the intended path.
Hiding tools entirely: AttachMCPSelector¶
Everything above decides whether a call to a tool that exists on the
server succeeds. It does not change which tools a caller sees when it
asks for the tool list — every caller gets the same catalog from
AttachMCP, and a tool a caller may not call is
simply refused when they try.
AttachMCPSelector is a different attachment for a server whose tool
catalog should depend on who is calling — a caller without the writer
role never sees purge-cache in the list at all, rather than seeing it
and being refused when they call it:
type MCPSelector func(ctx context.Context, id *identity.Identity) *mcp.Server
func (s *Server) AttachMCPSelector(selector MCPSelector)
selector runs once per HTTP request, before MCP-level handling, and
receives the same identity the tool-authorization middleware itself
would see. Build the fixed set of instances once — one per role, one per
tenant, however the catalogs are split — and have selector choose among
them; never build a *mcp.Server inside selector itself:
reader, editor := buildServers() // built once, at startup
gw.AttachMCPSelector(func(_ context.Context, id *identity.Identity) *mcp.Server {
if id == nil {
return reader
}
if roles, _ := id.Claims["roles"].([]any); containsEditor(roles) {
return editor
}
return reader
})
Both AttachMCP and AttachMCPSelector install the identical
tool-authorization middleware on every instance they wire, and both
force stateless sessions — see AttachMCP for why.
Being reachable through a selector's catalog is not the same as being
allowed to call what is in it: purge-cache on the editor instance
above still goes through access.Decider.Allow like any other writing
tool, so the three GANGWAY_WRITERS_* settings above still decide who
may actually call it. The selector only decides who sees it.
If selector returns nil, the request gets an HTTP 400 — the SDK's own
behaviour for an empty getServer result — never a default instance. A
selector that cannot place a caller has no basis to guess what that
caller should see, and a silent default would reopen exactly the kind of
unreviewable widening the rest of this section exists to prevent.
selector is expected to choose from a small, fixed, long-lived set.
Every previously-unseen instance it returns gets counted, and once more
than 1024 distinct instances have been seen over the server's lifetime
— only plausible if selector is building a new instance per request or
per caller instead of choosing from a fixed set — every further
previously-unseen instance is refused the same way a nil return is:
HTTP 400, plus a diagnostic line naming the limit on the configured log
writer (serve.WithLogWriter, stdout by default). Instances already
admitted before that point keep working; nothing already wired is torn
down.
Network¶
| Variable | Purpose | Default |
|---|---|---|
GANGWAY_ADDR |
The address the HTTP server listens on. | :8080 |