Skip to content

ADR-008: Environment-Aware Authorization Posture

This is a rendered copy of docs/adr/008-environment-aware-authorization.md, read in place from the repository — see therecords index for how to read it. If this page and the source file ever disagree, the source file is correct.

ADR-008: Environment-Aware Authorization Posture

Status: Accepted · Implemented (June 2026) Date: 2026-06-25 Authors: QAuth Team

Accepted 2026-06-25. Every fork below takes the fail-safe option (default to the strictest posture; relaxation is operator-controlled, never self-asserted). Implementation issues are derived from the sequencing section.

Implemented (June 2026, T5 milestone). All four sequencing items shipped: the environment schema + resolveEnvironmentPolicy resolver (#196), the production profile wired as the T3 hardening bundle (#197), and the environment-gated developer API keys — backend (#97) and portal UI (#98). The rate-limit tier was wired to the live /oauth/token and /oauth/authorize limits as a realm-level seam in the #209 follow-up.

Correction (2026-08-10): the “T3 hardening bundle” framing is narrower as implemented. Two statements below — the note above (“the production profile wired as the T3 hardening bundle (#197)”) and §5’s opening line — read as if the profile drives the T3 controls. #197 wired exactly one client-scoped knob, the consent screen’s style CSP; the T3 controls proper are global and unconditional. Both statements are left as recorded; the As implemented (#197) callout after the §5 table has what actually shipped.

Context

Two needs collided while finishing the MVP and planning T3:

  1. Developer experience. Static, long-lived API keys (issues #97/#98) make local development trivial — one header, no token-exchange dance. We want that ergonomics.
  2. Production safety. A static, non-expiring bearer is exactly what you do not want facing the internet. The same tension applies across the board: static API keys, long token lifespans, lenient rate limits, open dynamic registration, and relaxed agent step-up are all fine for local work and dangerous in production.

Loopback redirect carve-out (revised). http:// loopback redirect URIs (127.0.0.1 / [::1] / localhost) were originally development-only. They are now permitted in any environment that enforces PKCE (i.e. staging/production). Loopback redirects are the RFC 8252 standard for native / CLI clients — including every discover-then-register MCP client — and the traffic never leaves the device; PKCE (S256) is precisely what backstops loopback authorization-code interception on a shared host. Gating loopback on https (rather than on PKCE) only blocked the legitimate native-app flow without adding security, since validateRedirectUri already rejects all non-loopback plain-HTTP redirects. The fail-safe still holds: a non-development profile that drops PKCE would once again reject plain-HTTP loopback. See isRedirectUriAllowedForPolicy.

Today these are scattered, mostly-global knobs. T3 (OIDC conformance & hardening — #108 CSRF, #109 secure cookies, #113 helmet, …) is about to add more. Without a unifying concept, an operator hardening for production has to flip a dozen independent switches and get every one right, and a developer who wants local convenience has no safe way to opt into it.

The agent track already established the pattern we need. ADR-007 §2 shipped oauth_clients.max_agent_mode as an operator-set cap (its schema comment: “Unlike is_agent (self-asserted client input), this is OPERATOR-SET … A client omitting is_agent to dodge controls simply fails the check”), and realms.dynamic_registration_allowed_scopes is a realm-level hard cap on what self-registration may request. Environment posture is the same shape of problem: a security-relevant attribute whose relaxation direction must not be self-assertable.

Prior art (so the novelty claim is precise). The notion that environment changes credential policy is not new in SaaS: Stripe ships sk_test_/sk_live_ keys; Clerk, Auth0, and WorkOS separate development and production instances/tenants with different rules. What does not exist, to our knowledge, in the open-source, self-hostable OAuth/OIDC space (Keycloak, Ory Hydra, Authentik, etc. treat environments only as a deployment/realm convention): a first-class environment dimension on the realm/client within a single server that selects a security policy profile — and certainly not one that also modulates agent credential strictness. That synthesis is where QAuth is first.

Decision

Introduce environment as a first-class, policy-profile dimension.

1. The dimension

environment ∈ { development, staging, production }. It selects a policy profile — one attribute that flips a coordinated bundle of security defaults, instead of N independent knobs.

2. Granularity — client-level, bounded by a realm-level ceiling

  • 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, mirroring dynamic_registration_allowed_scopes. A client’s effective environment is the stricter of its own value and the realm ceiling. A realm set to production forces every client to the production profile regardless of the client’s own field — the ceiling can never be exceeded.

This serves the DX goal (a developer can mark one client development for local work) while giving operators a single realm-level lever to lock an entire deployment to production.

3. Fail-safe defaults (the safe method)

  • Unset client environmentproduction. An unconfigured client gets the strictest profile, never the laxest.
  • Unset realm max_environment_laxityproduction. A fresh realm caps everything at production until an operator deliberately widens it.
  • Net effect: the relaxed posture is opt-in and bounded; the default everywhere is the hardened one. Misconfiguration fails closed.

4. Relaxation is operator-controlled, never self-asserted

environment (in the relaxation direction) is set only by an operator — seed/manifest, admin API, or realm config. It is NOT accepted from POST /oauth/register (DCR) or a CIMD metadata document. A client cannot declare itself development to escape production gates, exactly as a client cannot self-grant max_agent_mode. (A client may be allowed to self-declare the stricter direction, since that only reduces its own privilege — but the simplest safe rule is operator-set, period, which this ADR adopts.)

5. The profiles

production is the strict baseline — and is the T3 hardening bundle (as implemented this is narrower than it reads — see the callout after the table). The laxer profiles relax specific knobs. Security-relevant relaxations apply to development only; staging keeps production-grade security and relaxes only operational conveniences (so promoting dev→staging surfaces security posture before production).

Knobdevelopmentstagingproduction
Static API keys (#97/#98)allowed, long-livedoff (or short-TTL, opt-in)off
http://localhost (loopback) redirect URIsallowedallowed (PKCE-backstopped)allowed (PKCE-backstopped)
PKCE (S256)recommendedrequiredrequired
Access-token lifespanlongshortshort
Refresh-token rotationoptionalrequiredrequired
Rate limitslenientlenient (load testing)strict
Open DCR / CIMDopengatedgated
Agent step-up before dangerous opsrelaxedenforcedenforced
Security headers / CSRF / secure cookies (T3)relaxedenforcedenforced

Each row is a default; an operator may override a single knob, but only within the realm ceiling and never below the hard security floors (e.g. a client secret is always hashed; audience binding always holds).

As implemented (#197) — the last row is narrower than it reads. The T3 controls proper are global, with no client in scope, so they were wired unconditionally and do not consult the environment profile at all: @fastify/helmet is registered globally, the /ui/login and /ui/consent CSRF checks always run, and the session cookie’s Secure attribute is driven solely by SESSION_COOKIE_SECURE. The resolved t3SecurityEnforced flag has exactly one consumer — the consent screen, the rare browser surface that unambiguously carries a client_id, which serves a relaxed style CSP (style-src 'self' 'unsafe-inline') for a development client. Marking a client production therefore enables none of the global controls, and marking it development disables none of them.

6. Static developer API keys (#97/#98), reframed

Build them — but environment-gated from day one. A static API key is a development (optionally staging) convenience; in production the API-key path is off and developers use OAuth client_credentials. This makes API keys the deliberate DX half of this ADR rather than a redundant second credential system competing with client_credentials.

7. Enforcement

A single resolveEnvironmentPolicy(client, realm) helper returns the effective profile (stricter of client vs realm ceiling, default production). Policy checkpoints — token issuance, the API-key gate, redirect-URI validation, DCR, agent step-up — consult it rather than re-deriving rules. This mirrors the toAgentScopeContext / enforceAgentScopeCap pattern from ADR-007 §2.

Consequences

Positive

  • One lever for posture. environment=production flips the whole hardened bundle on; operators stop hand-wiring a dozen switches.
  • Safe DX. Static keys and local conveniences exist without ever weakening a production deployment — relaxation is opt-in, bounded by the realm, and defaults off.
  • Gives T3 a backbone. The hardening work (#108/#109/#112/#113/…) becomes the production profile instead of scattered global flags; dev can relax individually without disabling it globally.
  • Env-aware agent strictness — novel: agent delegation/step-up can be strict in production and relaxed for local agent development.
  • First in OSS/self-hostable for environment-as-policy-dimension within one server (see Context).

Negative

  • New schema dimension + migration; environment must be threaded through every policy checkpoint (mitigated by the single resolver).
  • Operator misconfiguration is possible — mitigated by fail-safe defaults (unset = production) and the realm ceiling.
  • Three profiles add documentation and test surface; mitigated by keeping security relaxations to development only.

Neutral

  • Protocol-transparent: no OAuth/OIDC wire change; clients are unaffected by which profile they’re under beyond the policy outcomes.
  • Compatible with the realm-per-environment convention operators already use — this adds intra-server granularity, it doesn’t forbid the old pattern.

Implementation sequencing (issues to be derived; not part of this ADR)

  1. Schema + migration: oauth_clients.environment + realms.max_environment_laxity (default production); operator-only write paths.
  2. resolveEnvironmentPolicy(client, realm) resolver + the policy-profile table.
  3. Environment-gated static developer API keys (#97 backend / #98 portal UI).
  4. Wire the T3 hardening (#108/#109/#112/#113/…) as the production profile, with the dev relaxations.