Skip to main content

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.

Give each operator their own key

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โ€‹

RouteRequired 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
/metricsread_only or admin
/debug/* (/debug/vars, /debug/pprof/*)admin only
/health, /livez, /readyznone (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 unauthenticated
  • CORS_ORIGINS containing * โ€” matched literally against Origin, so it would allow no cross-origin request while reading as though it allowed all of them

Warned โ€” logged, startup continues:

  • RATE_LIMIT_RPS=0
  • ENABLE_PPROF=true
  • the in-memory API key store (operator keys, dashboard sessions, and the audit trail are lost on restart)