Status: Accepted
Date: 2026-04-30
Owner: software-architect
Related ADRs: 0031, 0032
Related design docs: docs/architecture/auth.md, docs/architecture/rbac-design.md, docs/security/web-surface-posture.md
Related issues/PRs: #110 (WebAuthn registration), #568 (CF Access MFA), PR #633 (TOTP/passkey MFA on CF Access), #534 (env-switcher design), #651 (support epic), #670 (multi-passkey enrollment + backup codes), #672 (this update)
Raxx authenticates users with a layered, defense-in-depth model. The governing principle:
The strongest factor lives at the layer closest to the user's actual capability. CF Access is for surface gating. App-level auth is for identity and capability.
Two corollaries follow from this:
The model is not static. Surfaces graduate between classes as app-level auth matures. The graduation rule is explicit and documented, not a silent drift.
These constraints are set at the product level. Any design, PR, or surface addition must comply.
CF Access policies are infrastructure decisions and do not create exceptions to the above.
Intended audience: end customers. Traffic is expected to be high, anonymous or authenticated at the app layer.
Intended audience: Raxx operators (Kristerpher and any future staff). The app layer provides a known, audited, phishing-resistant auth factor (passkey). CF Access provides a defense-in-depth outer perimeter.
Intended audience: Raxx operators, but via third-party tools (FreeScout, Infisical) whose auth implementation is not audited at our standard. We cannot guarantee these tools will always enforce our invariants.
auth_method: swk for hardware passkey). PR #633 implements this for vault.raxx.app and tickets.raxx.app.Intended audience: internal team (designers, engineers, contractors) viewing static HTML artifacts (mockups, internal docs). No app exists; CF Access is the sole authentication mechanism.
Current surfaces and their class assignments. Columns: surface | hostname | audience | app-level auth | CF Access role | CF Access MFA today | CF Access MFA target | rationale.
| Surface | Hostname | Audience | App auth | CF Access role | CF MFA today | CF MFA target | Class | Rationale |
|---|---|---|---|---|---|---|---|---|
| Antlers app | app.raxx.app |
Customers | WebAuthn passkey (#110) | None (public) | N/A | N/A | 1 | Customer funnel; app is the gate |
| Raxx brand / landing | raxx.app |
Public | None (marketing/redirect) | None (public) | N/A | N/A | 1 | Public marketing surface |
| Marketing site | getraxx.com |
Public | None (marketing) | None (public) | N/A | N/A | 1 | Public marketing site |
| Demo app | demo.raxx.app |
Customers / prospects | None or limited app auth | CF Access (allowlist, pre-GA) | Presence-only | Presence-only or public on GA | 1 | Pre-GA gate; relax to public on GA |
| Customer docs | docs.raxx.app |
Customers | None (public docs) | None (public) | N/A | N/A | 1 | Public documentation |
| Status page | status.raxx.app |
Public | None (read-only) | None (public) | N/A | N/A | 1 | Public trust signal; must be reachable without auth |
| Raxx Console | console.raxx.app |
Operators | WebAuthn passkey (epic #146, in-flight) | Allowlist gate | Strong MFA (TOTP, PR #633) | Presence-only once passkey GA | 2 | Transitional: strong CF MFA until app passkey GA; then relax |
| Infisical Vault | vault.raxx.app |
Operators + machine identities | Infisical-native (not audited) | Allowlist gate | Strong MFA (TOTP, PR #633) | Strong MFA always | 3 | Holds all credentials; defense-in-depth non-negotiable |
| FreeScout Tickets | tickets.raxx.app |
Operators | FreeScout-native (not audited) | Allowlist gate | Strong MFA (TOTP, PR #633) | Strong MFA always | 3 | FreeScout auth not audited at our standard |
| Internal docs | internal-docs.raxx.app |
Internal team | None (static) | Sole auth | Strong MFA | Strong MFA always | 4 | No app layer; CF Access is the only gate |
| Mockups | raxx-mockups.pages.dev |
Internal team | None (static) | Sole auth | Strong MFA | Strong MFA always | 4 | No app layer; CF Access is the only gate |
Surfaces not yet built (planned):
| Surface | Hostname | Anticipated class | Notes |
|---|---|---|---|
| API (post-GA) | api.raxx.app |
1 | Public API; app-layer auth (passkey-attested tokens) is the gate |
| API (pre-GA) | api.raxx.app |
Pre-GA: allowlist CF gate, no app MFA needed since it's machine-to-machine | Collapses to class 1 at GA |
A surface moves from Class 3 to Class 2 — and CF Access MFA is relaxed to presence-only — when all four conditions are met:
require: [auth_method: "swk"] or require: [auth_method: "mfa"] block is removed; the policy reverts to require: [] (presence-only, allowlist confirmed). The Terraform codification is updated in the same PR.Current console status: console.raxx.app is in transition. PR #633 applied strong MFA at CF Access. Once epic #146 (console passkey auth GA) closes, the graduation PR can be filed.
Use this flowchart when adding any new surface to the Raxx platform.
You are adding a new surface. Walk these questions in order:
1. WHO SEES IT?
├── Customers / general public
│ └── Go to [A]
├── Operators (Raxx team) only
│ └── Go to [B]
└── Mixed (customers + operators at different paths)
└── Split into two surfaces or apply the stricter class to the
restricted sub-path (e.g. /admin/* gets CF Access, /* is public)
[A] CUSTOMER / PUBLIC SURFACE
2. Does app-level auth exist and is it GA with passkeys?
├── Yes → Class 1. No CF Access gate. App passkey is the auth.
└── No → Pre-GA: add CF Access allowlist (presence-only, no MFA).
Remove CF Access gate when app auth goes GA.
[B] OPERATOR SURFACE
2. Is the app-level auth under Raxx control and auditable?
├── Yes, and passkey auth is GA → Class 2.
│ CF Access: presence-only allowlist gate. No extra MFA.
├── Yes, but passkey not yet GA → Class 2 transitional.
│ CF Access: strong MFA until passkey GA, then graduate.
└── No (third-party tool, or auth not audited at our standard)
└── Go to [C]
[C] THIRD-PARTY OR UNAUDITED APP AUTH
3. Does the surface serve credential-bearing or privileged data?
├── Yes → Class 3. CF Access strong MFA always. Never relaxable
│ without replacing the tool with an audited one.
└── No → Class 3. Same rule applies: we cannot verify the tool's
auth, so CF Access carries the strong factor regardless.
[D] STATIC INTERNAL (no app layer at all)
The surface is static HTML, documentation, or a read-only artifact.
→ Class 4. CF Access strong MFA is the sole auth. Non-negotiable.
Test case — social-media-team@raxx.app hypothetical:
If this is a Notion-style workspace or a dedicated surface for social media contractors (not Raxx employees, limited trust), the walk is: Audience = operators (contractors) → app auth = third-party tool (not audited) → Class 3. CF Access strong MFA. If it is a shared static brief page, Class 4.
These anti-patterns create friction without security gain or create false coverage:
| Anti-pattern | Why it is wrong |
|---|---|
| CF Access strong MFA on top of app-level passkey GA | The passkey already satisfies AAL2. Adding a TOTP prompt on top means the operator enters TOTP twice per session: once at CF, once conceptually at the app. No additional attacker surface is blocked. Relaxing to presence-only after graduation is correct. |
| Relying on CF Access alone for credential-holding surfaces | CF Access is a surface gate, not an app auth. An attacker who compromises a CF Access session gets into the tool; the tool's own auth must also resist the attacker. Class 3 surfaces layer CF Access + tool auth precisely for this reason. Never deploy a new vault/secrets surface as Class 4. |
| Omitting CF Access entirely on operator surfaces even after app passkey is GA | Defense-in-depth means CF Access stays as the outer perimeter, even when it is relaxed to presence-only. Removing CF Access entirely from an operator surface eliminates the layer that catches misconfiguration or breach in the app-layer auth itself. |
| Applying presence-only CF Access to a Class 3 (unaudited app auth) surface | If you cannot audit the tool's auth, you cannot treat a presence check as sufficient. Class 3 always keeps strong MFA at CF Access. |
The auth-posture doc itself does not store PII. The surfaces it governs do:
docs/architecture/auth.md §3. Retention, erasure, and portability rules follow ADR-0003 and the GDPR endpoints in auth.md §4.| Event | Where logged | Retention |
|---|---|---|
| Operator CF Access login | Cloudflare Zero Trust access logs | Per CF account settings (30–180 days) |
| Operator console login (post #146) | Console audit_log | 2 years (DPA) |
| Privileged action in console | Console audit_log | 2 years |
| Customer passkey registration | Raptor audit_log | 2 years |
| Customer login | Raptor audit_log | 2 years |
| DSR export / erase | Raptor audit_log | 2 years |
CF_ACCESS_MGMT (was CLOUDFLARE_ACCESS_MGMT_TOKEN) — Infisical, rotatable without redeploy.cf_totp_idp_id, cf_onetimepin_idp_id) — Terraform variables, updated via terraform apply.WEBAUTHN_RP_ID, WEBAUTHN_ORIGIN — env vars, validated at app boot.AUTH_DISABLED=1 env var short-circuits all /api/auth/* endpoints to 503, per auth.md §8.CF_ACCESS_MGMT (was CLOUDFLARE_ACCESS_MGMT_TOKEN) API without a redeploy. This is the emergency kill-switch for any CF-gated surface.Any breach.* event written to the audit log triggers the GitHub Action runbook in docs/agents/security_response.md. GDPR Art. 33 (72-hour supervisory authority notification window) is tracked from the breach.* write timestamp. Runbook is authoritative; this doc does not duplicate it.
| Class | Description | Target session lifetime | Rationale |
|---|---|---|---|
| 1 | Customer (app passkey) | 12h rolling (per auth.md §6) |
Balances UX with security; step-up required for privileged actions |
| 2 | Operator with strong app auth | ≤8h fixed | Operator surfaces; shorter lifetime limits blast radius of session compromise |
| 3 | Operator via unaudited tool | ≤4h (CF Access session) | Shorter because app-layer revocation is not under our control |
| 4 | Static internal | 24h (CF Access session) | Read-only; longer session is acceptable friction tradeoff for internal team |
These are targets. Current CF Access session duration defaults to 24h across all gated apps (see PR preview bot comment on PR #633). Hardening session lifetimes per class is a follow-up task.
These require Kristerpher's decision before downstream work can be finalized.
SAML federation trigger. At what contractor count or contractor diversity does the org move from CF Zero Trust's built-in IdPs to a SAML provider (Okta, Google Workspace SSO)? This affects how CF Access MFA is enrolled for non-operator users. Proposal: trigger = first contractor who is not on moosequest.net or raxx.app email domain, or at 5+ distinct operator identities, whichever comes first.
Hardware-key requirement. When does WebAuthn passkey enrollment become hardware-key-required (YubiKey / Titan) vs platform-allowed (Touch ID, Face ID, Windows Hello)? Proposal: hardware-required triggers on (a) a regulatory event (SEC/FINRA licensing), or (b) first contractor with vault or token-admin access. Until then, platform authenticators are accepted.
Session lifetime enforcement. The targets in §9 are proposals. CF Access session durations are currently 24h for all gated apps. When do we harden these per-class? This is a Terraform change per app, low-risk, but needs a decision on the exact values before filing the sub-card.
demo.raxx.app graduation. The demo surface is pre-GA gated by CF Access presence-only. When does it go public? Trigger should be: app auth is GA + demo is feature-flagged for a known subset of flows. Needs a product decision on what "demo" means at GA.
~~Tickets path (tickets.raxx.app vs support.raxx.app).~~ Resolved 2026-04-30 (#716). The CF Access app for tickets.raxx.app/admin was dead config — FreeScout does not expose an /admin path. The CF Access app (eafdf1f1-c2f1-4847-8777-c54acf22aca5) has been deleted. FreeScout's canonical login URL is tickets.raxx.app/login (built-in FreeScout auth, no CF Access gate). The surface taxonomy row for FreeScout Tickets in §4 reflects the correct state: Class 3, CF Access gate on the root domain, no /admin sub-path.
Decision date: 2026-04-30 UTC
Decision owner: Kristerpher
Implementation card: #670
ADR: ADR-0032
Customer accounts sit in Class 1 (customer-facing public, app is the auth layer). CF Access is not applied to the customer app surface. The account recovery policy applies to that class.
Customer account recovery is limited to two mechanisms, both provisioned at signup:
A. Multi-passkey enrollment (non-skippable gate)
Signup requires at least two passkeys enrolled before the account is activated. The enrollment step cannot be skipped. If a customer has only one device available at signup, they can pause and resume — the account does not go live until a second passkey exists. This is enforced at the API layer, not just the UI.
Customers can add and revoke passkeys from the account dashboard at any time after signup. The enrolled-device list is always visible so customers know their recovery state.
B. Backup codes (non-skippable save gate)
Ten single-use backup codes are generated at signup. The signup flow does not complete until:
Using a backup code does not restore normal access directly. It opens a forced passkey re-enrollment flow; the customer must enroll a new passkey before the session can do anything else. Each backup code is single-use — it is burned in the audit log on consumption.
Customers can regenerate backup codes from the dashboard. Regeneration invalidates the entire previous batch immediately.
There is no third recovery path. The following are explicitly not features of this platform:
These are not omissions to be filled later. They are deliberate non-features. ADR-0032 records the full rationale for each rejection.
If a customer loses all enrolled devices and all backup codes, the account is permanently inaccessible. Raxx cannot recover it. This is a deliberate posture: account-takeover defense takes priority over convenience for a financial-services platform. Permanent lockout is the cost of not building a phishable recovery surface.
Signup copy requirement: the customer-facing signup flow must state this in plain language before signup completes. Suggested copy: "Account recovery is not possible without your enrolled devices or backup codes — please save both before completing signup." The copy must not soften this to the point of misrepresenting the risk.
Support agents must not promise or attempt recovery for a customer who has lost both factors. Doing so creates a social-engineering vector. The correct response is: "Account recovery is not possible without your enrolled devices or backup codes. This is a security decision that protects your account from takeover."
A full response template should land in docs/ops/runbooks/support/ as part of the support epic work (#651). That directory does not exist yet — it is not created here.
Every passkey and backup-code event is written to the audit log:
| Action | Event name |
|---|---|
| Customer adds a passkey | customer.passkey.added |
| Customer revokes a passkey | customer.passkey.revoked |
| Customer uses a backup code | customer.backup_code.used |
| Customer regenerates backup codes | customer.backup_codes.regenerated |
Retention: 2 years (DPA requirement, consistent with §8 audit trail table).
Backup codes are short-lived, single-use values. They are not stored in recoverable form after generation (per ADR-0002). On a DSR erasure request, all backup code records (consumed and unconsumed) are deleted with the account. Passkey credential IDs and public keys follow the erasure rules in auth.md §4.
auth_method: "swk" (short-key, platform/hardware key), "otp" (TOTP), "mfa" (any second factor)