Authentication
The gateway has no user accounts โ a bearer token is the credential, and every
authenticated route (admin API, /v1/* data plane, /metrics, /debug/*)
checks the same credential chain: MASTER_KEY, a stored API key, or a
dashboard session minted from either.
Provider credentialsโ
Set provider-specific credentials as environment variables. The gateway injects these when proxying requests upstream; a provider is only registered when its required variable is present.
export OPENAI_API_KEY=sk-your-key
MASTER_KEY: bootstrap and break-glassโ
MASTER_KEY is the credential that gets you started and the way back in if
every stored key is lost โ not a daily login. Generate one and export it
before starting the gateway:
ferrogw init
export MASTER_KEY="fgw_..." # generated by `ferrogw init`
MASTER_KEY is compared with a constant-time check and authenticates as a
synthetic admin-scoped key (master-key:<fingerprint>). It has no row in
the key store, so unlike a stored key it cannot be revoked or expired
without restarting the process โ rotating or unsetting the value invalidates
it and any session minted from it immediately, since the check re-derives the
fingerprint on every request.
Use MASTER_KEY to bootstrap, then create one admin-scoped key per operator
via POST /admin/keys or the dashboard. A shared key has to be rotated and
redistributed to everyone when one person leaves; a per-operator key is
revoked on its own and its actions are attributable in the audit trail. See
Virtual keys and API keys for key management.
Issuing scoped API keysโ
Once MASTER_KEY (or an admin-scoped key) is set, use it as a bearer token to
call the admin API and issue persistent, scoped keys:
curl -X POST http://localhost:8080/admin/keys \
-H "Authorization: Bearer $MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "ci-pipeline", "scopes": ["admin"]}'
Issued keys are prefixed fgw_ (64 hex characters) โ never ferro-....
The full secret is returned only once, in the creation response; the store
keeps a SHA-256 hash.
Two scopes exist: admin (full access) and read_only. Omitting scopes
on creation defaults to read_only โ least privilege by default, not
admin. A caller wanting an admin-scoped key must request it explicitly:
# Defaults to read_only โ scopes omitted
curl -X POST http://localhost:8080/admin/keys \
-H "Authorization: Bearer $MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "monitoring-scraper"}'
Naming a scope outside {admin, read_only} fails with 400 invalid_scope.
Client request authenticationโ
By default, all /v1/* routes require authentication
(/v1/chat/completions, /v1/completions, /v1/embeddings,
/v1/images/generations, /v1/models, and the pass-through proxy). Clients
send a bearer token โ either MASTER_KEY or an issued fgw_ key:
curl http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer $MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"Hello"}]}'
Data-plane requests accept the same credential chain as the admin API โ including a dashboard session token โ so the embedded dashboard's Playground authenticates identically to a CLI client.
To disable proxy authentication for local development only, set
ALLOW_UNAUTHENTICATED_PROXY=true. The gateway logs a warning on startup when
this is enabled, and production mode refuses to start with
it set.
Dashboard sessionsโ
The embedded dashboard doesn't hold a raw API key in the browser. It exchanges one for a short-lived session token:
curl -X POST http://localhost:8080/admin/session \
-H "Authorization: Bearer $MASTER_KEY"
POST /admin/session is deliberately unauthenticated as a route โ the
bearer token presented in the request body's place is the credential being
validated, so this is how a caller obtains the session token every other
/admin/* call needs. The minted session:
- carries only the scopes of the credential that minted it
- expires after 24 hours absolute or 1 hour idle, whichever comes first
- is revocable per-session (
DELETE /admin/sessions/{id}) or all at once (DELETE /admin/sessions) - is stored hashed, following the same backend as the API key store
(
API_KEY_STORE_BACKEND)
Sign-in attempts against POST /admin/session are throttled independently of
the general per-IP rate limit, so tuning RATE_LIMIT_RPS for inference
traffic can't loosen this endpoint. Every attempt โ not only failures โ
consumes a token, since a flood of valid requests costs the same store lookup
and hash comparison as an invalid one. Accepted and denied sign-ins are
recorded in the audit trail.
Scope requirements by routeโ
| Route | Required scope |
|---|---|
/v1/* (data plane) | any valid credential, unless ALLOW_UNAUTHENTICATED_PROXY=true |
/admin/* (except POST /admin/session) | read_only or admin, depending on the operation |
/metrics | read_only or admin |
/debug/* (/debug/vars, /debug/pprof/*) | admin only |
/health, /livez, /readyz | none (unauthenticated) |
/metrics and /debug are split into two tiers on purpose: a monitoring
system scraping /metrics and an engineer pulling a heap or goroutine profile
are different actors, and a profile can hold request bodies, prompts, and
credentials โ the reason /debug/* is admin-only rather than sharing the
read-only tier /metrics uses.
Production modeโ
Setting GATEWAY_ENV=production turns on startup safety checks that split
into two tiers:
Refused โ the gateway exits rather than start:
ALLOW_UNAUTHENTICATED_PROXY=trueโ every/v1/*route would be unauthenticatedCORS_ORIGINScontaining*โ matched literally againstOrigin, so it would allow no cross-origin request while reading as though it allowed all of them
Warned โ logged, startup continues:
RATE_LIMIT_RPS=0ENABLE_PPROF=true- the in-memory API key store (operator keys, dashboard sessions, and the audit trail are lost on restart)
Relatedโ
- Virtual keys and API keys โ key lifecycle, expiry, and revocation
- Rate limiting โ per-IP and per-key limits
- Server settings โ
GATEWAY_ENV,TRUSTED_PROXIES, and other env vars - Admin API reference โ full
/admin/*endpoint contract