QAuth treats environment — development, staging, or production — as a
first-class policy-profile dimension. One attribute flips a coordinated bundle
of security and operational defaults, so an operator hardens a deployment by
setting a single value instead of getting a dozen independent switches right.
This is the operator/how-to guide. For the design rationale, the fail-safe
reasoning, and the prior-art comparison, see
ADR-008. The resolver itself lives
in apps/auth-server/src/app/helpers/environment-policy.ts.
Environment is set on two columns, both defaulting to production:
| Column | Meaning |
|---|---|
oauth_clients.environment |
The client's declared environment. |
realms.max_environment_laxity |
A realm-level ceiling on how lax any client in that realm may be. |
The effective environment is the stricter of the two
(production > staging > development). A realm pinned to production forces
every client in it to the production profile regardless of the client's own field.
effective = stricter(client.environment, realm.max_environment_laxity)
- Unset / unknown client
environment→production. - Unset / unknown realm
max_environment_laxity→production.
A fresh realm caps everything at production until an operator deliberately widens it, and any malformed value resolves to the strictest profile. Misconfiguration fails closed — the relaxed posture is always opt-in and bounded.
The relaxation direction is set only by an operator — seed/manifest, admin
API, or realm config. It is not accepted from POST /oauth/register (dynamic
client registration) or a CIMD metadata document. A client cannot declare itself
development to escape production gates, exactly as it cannot self-grant
max_agent_mode (ADR-007 §2).
Security-relevant relaxations apply to development only. staging keeps
production-grade security and relaxes only operational conveniences (rate limits,
token lifespan), so promoting dev → staging surfaces the real security posture
before production.
Knob (EnvironmentPolicy field) |
development | staging | production |
|---|---|---|---|
staticApiKeysAllowed |
✅ allowed | ❌ off | ❌ off |
localhostRedirectAllowed |
✅ | ❌ | ❌ |
pkceRequired |
recommended | ✅ | ✅ |
accessTokenLifespanTier |
long | short | short |
refreshRotationRequired |
❌ | ✅ | ✅ |
rateLimitTier |
lenient | lenient | strict |
openDynamicRegistration |
open | gated | gated |
agentStepUpEnforced |
❌ | ✅ | ✅ |
t3SecurityEnforced (headers/CSRF/cookies) |
❌ | ✅ | ✅ |
Notes:
pkceRequiredhere governs whether the environment profile hard-requires PKCE. QAuth's project-wide floor still defaultsoauth_clients.require_pkce=trueregardless, so PKCE is on unless a client is explicitly adevelopmentclient that opts out.stagingandproductionare https-only for redirect URIs. The RFC 8252http://loopback carve-out for native / CLI clients (including MCP clients) is handled by redirect validation and is permitted in any PKCE-enforcing environment — it is not the same flag aslocalhostRedirectAllowed.- Hard security floors always hold and are not environment-tunable: client
secrets are always hashed (Argon2id), and audience (
aud) binding always holds.
Static, long-lived API keys are the deliberate developer-experience half of this feature — and they are environment-gated from day one:
development: a client may issue and authenticate with static API keys.staging/production: the API-key path is off; use OAuthclient_credentialsinstead.
Keys have the layout qauth_<keyId>_<secret> and are presented as a bearer
credential:
Authorization: Bearer qauth_<keyId>_<secret>Only keyHash / prefix (qauth_<keyId>) / last4 are ever persisted; the full
key is surfaced exactly once at creation. Manage them from the developer portal
(API keys section) or the /api/clients API. The gate is enforced centrally
via resolveEnvironmentPolicy(client, realm).staticApiKeysAllowed, so a key that
exists on a client later moved to production simply stops authenticating.
The profile table is deliberately coarse — it carries legible tier labels, not magic numbers. The concrete values come from configuration so an operator tunes one place:
- Access-token lifespan —
shortmaps toACCESS_TOKEN_LIFESPAN(default900seconds);longis the development convenience. - Rate limits —
strictvslenientselect between configured per-window caps (RATE_LIMIT_MAX/RATE_LIMIT_WINDOWand the per-endpoint limits); only the cap moves with environment, the window is unchanged.
- Production deployment: leave both columns at their
productiondefault, or explicitly pinrealms.max_environment_laxity = productionto lock the entire realm. Nothing else to configure — the T3 hardening bundle is on. - Local development: set the developer's realm
max_environment_laxitytodevelopment(orstaging) and mark specific clientsenvironment = developmentto opt into static API keys, localhost redirects, and long-lived tokens — without ever weakening the production realm. - Staging / load testing: use
stagingto keep full production security while relaxing only rate limits and token lifespans.
- ADR-008: Environment-Aware Authorization Posture — rationale, fail-safe design, prior art.
- ADR-007: MCP-First Positioning — the operator-set
max_agent_modeprecedent this reuses. - Agent Authorization — agent step-up, which this posture enforces in
staging/production. - Browser Security — the T3 hardening bundle that the production profile turns on.