Skip to content

Environment-Aware Authorization

QAuth treats environmentdevelopment, 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:

ColumnMeaning
oauth_clients.environmentThe client’s declared environment.
realms.max_environment_laxityA 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 environmentproduction.
  • Unset / unknown realm max_environment_laxityproduction.

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.

Relaxation is operator-set, never self-asserted

Section titled “Relaxation is operator-set, never self-asserted”

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)developmentstagingproduction
staticApiKeysAllowed✅ allowed❌ off❌ off
localhostRedirectAllowed
pkceRequiredrecommended
accessTokenLifespanTierlongshortshort
refreshRotationRequired
rateLimitTierlenientlenientstrict
openDynamicRegistrationopengatedgated
agentStepUpEnforced
t3SecurityEnforced (style CSP on consent)

Notes:

  • t3SecurityEnforced is narrower than its name suggests, and the row above is the whole of it. The flag has exactly one consumer: when it is false, the consent screen serves a relaxed style CSP (style-src 'self' 'unsafe-inline'). The rest of the T3 browser hardening is unconditional and never reads the environment profile — @fastify/helmet is registered globally (apps/auth-server/src/app/plugins/security-headers.ts), the /ui/login and /ui/consent CSRF checks always run, and the session cookie’s Secure attribute is driven solely by SESSION_COOKIE_SECURE (apps/auth-server/src/app/helpers/session-cookie.ts). Marking a client production does not turn any of those on, and marking it development does not turn them off. See Browser Security.
  • pkceRequired here governs whether the environment profile hard-requires PKCE. QAuth’s project-wide floor still defaults oauth_clients.require_pkce=true regardless, so PKCE is on unless a client is explicitly a development client that opts out.
  • staging and production are https-only for redirect URIs. The RFC 8252 http:// 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 as localhostRedirectAllowed.
  • 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 OAuth client_credentials instead.

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 lifespanshort maps to ACCESS_TOKEN_LIFESPAN (default 900 seconds); long is the development convenience.
  • Rate limitsstrict vs lenient select between configured per-window caps (RATE_LIMIT_MAX / RATE_LIMIT_WINDOW and the per-endpoint limits); only the cap moves with environment, the window is unchanged.
  • Production deployment: leave both columns at their production default, or explicitly pin realms.max_environment_laxity = production to lock the entire realm. Nothing else to configure on this dimension — but note the T3 browser hardening (security headers, CSRF, the cookie Secure flag) is not part of it: those are unconditional and configured separately, chiefly via SESSION_COOKIE_SECURE and the SECURITY_HSTS_* variables.
  • Local development: set the developer’s realm max_environment_laxity to development (or staging) and mark specific clients environment = development to opt into static API keys, localhost redirects, and long-lived tokens — without ever weakening the production realm.
  • Staging / load testing: use staging to keep full production security while relaxing only rate limits and token lifespans.