---
status: in-progress
---

# The auth plane moves to AWS Cognito and API keys

> **Status: dashboards done, API half proposed (2026-10-07).** All three dashboards sign in through
> Cognito in dev and prod, and PropelAuth is out of the code and the database: the dashboard halves
> of Phases 1, 4 and 5 are built and Phase 2 is done in the repo, including the drop of the legacy
> columns (`20261010040000_drop_legacy_auth_columns.sql`; `admin.dashboard_user.cf_access_sub` in `20261010050000_drop_admin_dashboard_user_cf_access_sub.sql`), with the out-of-repo teardown still
> pending (listed under Phase 2). Scoped API tokens, the authorizer's Cognito source, MoveMoney,
> checkout widgets and the dashboards' cutover to the APIs (Rail A, Phases 3, 6 and 7) are still
> proposed, with no code written. Each phase
> below carries its own status line.
>
> Interview-driven design, 2026-09-03. Composes with
> [API_AUTHORIZER_RUST.md](API_AUTHORIZER_RUST.md) (PR #3563) rather than competing with it, and
> maps onto the six Cognito Auth project issues (ENG-3052, 3053, 3054, 3067, 3068, 3070).

Related: [Cognito tokens are verified in the API, not at the gateway](COGNITO_MANAGED_AUTH_AND_DELEGATION.md)
proposes three changes to this design: verify bearer tokens in `authenticate()` rather than at the
gateway, key machine principals by Cognito `client_id` instead of token claims, and start with one
scope.

## Why

All four drivers apply:

- **Hosted checkout widgets** — embeddable checkout with OAuth + PKCE scoped to `customer_id` + `entity_id`.
- **Entity-level API scoping** — every authenticated request today scopes to a `customer_id` only; APIs need to narrow to a single entity within a customer. (Entity-level access exists as data today — `b2b.entity_product_block` per-entity product gating, see [ENTITY_PRODUCT_ACCESS.md](ENTITY_PRODUCT_ACCESS.md); the gap is credential-level scoping. Product blocks have since been removed; per-entity gating is now an entity-scoped activation rule.)
- **PropelAuth limits** — the customer-dashboard session JWT outgrew its size budget (`docs/AUTH.md`); permissions in tokens do not scale.
- **AWS consolidation** — one vendor fewer, auth next to the infra, native IAM/OIDC integration.

Plus the unification goal: **API auth and dashboard auth converge** — scoped tokens created in the API, the way scopes already work in the dashboards.

## Current state

| Surface                                       | Auth today                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Customer dashboard                            | Cognito `cfx-platform` sessions; roles and permissions read from `b2b.customer_dashboard_*` per request. When this was written: PropelAuth sessions, with roles and permissions in the session JWT                                                                                                                                                                                                                                                                         |
| Bank dashboard                                | Cognito `cfx-bank` sessions; the principal is the partner bank, with permissions read from `bank.bank_dashboard_*`. When this was written: PropelAuth sessions                                                                                                                                                                                                                                                                                                             |
| APIs (11 customer-facing)                     | `x-api-key`/`x-api-secret` validated in-process per request (Valkey-cached hash lookup, bcrypt compare) via `authenticate()` ([`packages/controllers/src/lambda/auth.ts:46-89`](../../packages/controllers/src/lambda/auth.ts)), the single resolution point every API reaches through `createHttpLambdaHandler`; `customerId` flows into logs, request context, and DynamoDB idempotency keying; 12 webhook routes authenticate by body signature instead (`auth: false`) |
| Admin dashboard                               | Cognito `cfx-admin` sessions (Google Workspace) plus DB-backed RBAC (`docs/AUTH.md`). When this was written: Cloudflare Access identity, already PropelAuth-free                                                                                                                                                                                                                                                                                                           |
| MoveMoney (`cfx-demo/packages/movemoney-app`) | Its own auth plane: iron-session cookie, self-verified JWT (`AUTH_JWT_SECRET`), tenant-scoped identity, phone-OTP login, in-app permission derivation (`src/lib/access.ts` computes `*.transact` permissions per request). Platform calls ride one static app-wide `COINFX_API_KEY`/`COINFX_API_SECRET` pair; the entity scoping it needs is done in its own code, not in the credential                                                                                   |

## What lands: the credential plane

Shared by all surfaces. Lands once; never changes per surface.

### User pools (one per audience)

| Pool           | Audience                                                    | SSO story                                                                                                                                                                                            |
| -------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cfx-platform` | Customer dashboard users, MoveMoney users, checkout widgets | Passwordless phone OTP — the login UX MoveMoney uses today, moved into the pool; email fallback; passkey 2FA pool-wide (dashboards, MoveMoney, checkout widgets); per-customer OIDC federation later |
| `cfx-bank`     | Bank dashboard users (partner-bank staff)                   | Passwordless at cutover with passkey 2FA; federation as needed                                                                                                                                       |
| `cfx-admin`    | CFX operators                                               | Google Workspace via Cognito OIDC federation, linked to a native user that holds a step-up passkey (sign-in 2FA stays in Workspace — see below)                                                      |

The authorizer enforces issuer-to-surface mapping: a customer-pool token cannot validate on admin or bank routes, and vice versa. Cross-surface token replay is structurally impossible, not a scope check someone has to remember.

### Sign-in factors and MFA

Dashboards get passkeys as the second factor, on Cognito's choice-based authentication (Essentials/Plus feature plans):

- A passkey with user verification satisfies required MFA (`FactorConfiguration` → `MULTI_FACTOR_WITH_USER_VERIFICATION`). But OTP-first flows are incompatible with required MFA, so the flow for a passkey-enrolled user is passkey-first — phone OTP is not a step-up after OTP. OTP remains the first factor for users without a passkey, and the recovery path for lost devices.
- Admin users sign in through Google Workspace, and sign-in 2FA is Workspace policy. Cognito gives a passkey only to a native user or a federated identity linked to one, so the pre sign-up Lambda links each operator to a native user, which holds a passkey for step-up MFA only. The admin client carries `ALLOW_USER_AUTH` because passkey registration requires it, but admin-dashboard never offers a passkey sign-in ([cognito README](../../packages/cognito/README.md#cfx-admin-passkeys-and-the-operator-relink)).
- Passkeys are a pool-wide capability on `cfx-platform`: MoveMoney users and checkout-widget end-users enroll alongside dashboard users, and the hosted UI offers the passkey option at sign-in. Widget tokens keep their entity-scoped binding and short TTLs regardless of factor.

### Token and claim model

Claims are injected at issuance by a Pre-Token Generation Lambda resolving against our DB:

| Claim         | Presence                       | Source                                                                                                                                                                                                                                                                                                                                        |
| ------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `customer_id` | always                         | Customer domain: the customer. Bank domain: the bank in play — the reachable set is what `getAuthBankIds()` resolves today, which can be several banks per staff member; the token carries the one in play and switching banks re-auths, the same mechanism as the customer workspace switcher. Admin domain: absent; operator identity only. |
| `entity_id`   | optional, customer domain only | Entity membership; bound at consent for the entity-scoped kind (checkout widgets, MoveMoney). Never present on bank or admin tokens.                                                                                                                                                                                                          |
| `principal`   | always                         | `user` \| `service_account` \| `api_token`                                                                                                                                                                                                                                                                                                    |

Dashboard sessions are customer-wide (no `entity_id`). Entity scoping is narrow-on-demand. Dashboard users already span multiple workspaces (`getAuthWorkspaces` resolves a resource across all of them today); a session carries exactly one `customer_id` claim, and the workspace switcher re-auths to switch it.

### Edge authorizer

One API Gateway REQUEST authorizer, per the authorizer RFC, accepting three credential forms:

1. The `x-api-key`/`x-api-secret` pair (today's form, tolerated indefinitely for legacy callers)
2. A Cognito access token (JWKS per pool, issuer and audience checked)
3. A minted JWT used directly as a bearer credential (token-exchange endpoint)

The RFC's gateway result caching and this credential list conflict: with caching enabled, API Gateway returns 401 without invoking the authorizer when a configured identity source is missing — and a bearer request carries neither credential header. The token sources therefore cannot ride the pair's identity-source list. Resolution: once the token forms land (Phase 1), the combined authorizer runs **without** gateway result caching and caches credential→context in Valkey itself, at the same TTL budget the RFC signs off — revocation semantics are preserved rather than reopened. The gateway cache survives only in Rail A's pair-only pilot form.

All three resolve to the same principal context on `requestContext.authorizer`: `customerId`, `entityId` when present, `principal`, `roleId`. The context is what handlers read; headers pass through unchanged, which keeps DynamoDB idempotency keying intact.

A REQUEST authorizer rather than API Gateway's native `COGNITO_USER_POOLS` authorizer, because the native one validates a Cognito token and nothing else. It cannot accept the credential pair, so adopting it would mean either breaking every existing API-key caller or standing up a second domain for token callers. One Lambda in the auth path buys both credential forms on the same route, and it is the same Lambda the authorizer RFC already proposes.

### Auth needs no new API surface

The authorizer sits on top of the backend as it is. Accepting a token needs no `/v2` path, no route rewrite and no per-API migration: everything auth delivers is reachable from the authorizer alone, so auth was never a reason to add a surface.

A `/v2` surface was adopted afterwards, for other reasons. [API_V2.md](API_V2.md) publishes the domain types on `/v2` for the money-movement APIs. With unified auth, the dashboards cut over to those APIs and stop importing services ([RUST_SERVICE.md § 1.1](../RUST_SERVICE.md#11-the-dashboards-call-the-v2-apis), Phase 7 below). The authorizer is path-agnostic, so `/v1` and `/v2` take the same credentials.

The reason it is this cheap is that `customerId` has exactly one resolution point. `authenticate()` ([`packages/controllers/src/lambda/auth.ts:46-89`](../../packages/controllers/src/lambda/auth.ts)) is where every request gets its customer, `AuthStrategy` is a single function type, and all sixteen APIs reach it through `createHttpLambdaHandler`. Accepting a Cognito identity is one new branch there, reading `requestContext.authorizer` before falling through to today's pair strategy. Controllers, routes, interface packages and services are untouched.

Two facts that shape the work, both verified against the tree rather than inherited from the authorizer RFC:

- **`proxyAll` cannot attach an authorizer at all.** Its type is `{ apiKeyRequired: Input<boolean> }` and nothing else ([`LambdaRestApi.ts:217-220`](../../packages/pulumi-templates/src/LambdaRestApi.ts)); `authorizerRequired` exists only on the explicit `rootPath` (`:182`) and `paths` (`:230`) shapes, and `proxyAll` is mutually exclusive with both (`:311-314`). **Twelve APIs use `proxyAll`** — not the eleven the authorizer RFC records. The fix is additive and small: add `authorizerRequired` to the `proxyAll` type so the flag reaches both the root and the `{proxy+}` method. Without it, no proxied API can be authorized at all.
- **The template's authorizer cache defaults to 300s** ([`LambdaRestApi.ts:1119-1121`](../../packages/pulumi-templates/src/LambdaRestApi.ts)), five times the 60s cap the authorizer RFC proposes. Whatever revocation budget is signed off must be set explicitly; inheriting the default silently widens the window.

`customAuthorizer.type` is already an unconstrained `Input<string>` passed straight through (`:1112`), and no stack passes `customAuthorizer` today, so this plumbing has no existing consumer to break.

### The unified scope model (API auth meets dashboard auth)

The RBAC model becomes the single scoping language. Same permission catalog (`<resource>:<action>`), same role-as-bag-of-permissions, same DB-backed tables, applied to both dashboard sessions and API credentials.

Machine principals join the customer-domain RBAC. Creating a scoped API token offers four kinds:

| Token kind           | Effective scope                                                                                                                          | Entity scope         |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| Entire customer      | Full access, mimicking today's keys (legacy-compatible)                                                                                  | Never, customer-wide |
| User-delegated       | Inherits a dashboard user's role and permission set, resolved live at check time (role changes re-scope the token; no snapshot to drift) | Optional             |
| Service account      | Its own role assignment in the customer RBAC                                                                                             | Optional             |
| Entity-scoped (PKCE) | Bound to one entity at widget consent, with the widget's minimal role                                                                    | Always, mandatory    |

Token management joins the workspace-management pages (ENG-3054 — its current scope needs extending to cover tokens): create, scope, assign role, revoke. Today's API-key rows migrate into the "entire customer" kind, so existing callers keep working unchanged.

**A migrated key's scopes are its customer's existing plan grants, and within each granted API it is unrestricted.** The pairs have no scope column of their own, but the entitlement is already recorded elsewhere: `b2b.customer_api_plan` holds one row per (customer, `api_stage`) grant, live while `deleted_at IS NULL` ([`customer_api_plan.ts:19-40`](../../packages/db/src/drizzle/schema/customer_api_plan.ts)). The backfill is a projection of that table, not a derivation:

```sql
SELECT
  api_stage
FROM
  b2b.customer_api_plan
WHERE
  customer_id = $1
  AND deleted_at IS NULL;
```

Each returned `api_stage` is a scope. Nothing is inferred and nothing is guessed.

This is behaviour-preserving by construction rather than by choice. The plan grant is already what API Gateway enforces: a key whose customer holds no plan for an API gets `403 INVALID_API_KEY` on it today. Projecting the grants into scopes re-expresses a boundary that already exists and is already enforced — it does not invent one. No request that succeeds today is refused afterwards, and no request that fails today starts succeeding.

It is also genuinely least-privilege at the API grain, which the alternative — granting every scope to every key — would not have been. Narrowing further, inside an API, stays a customer-facing change on its own schedule.

**Permissions never appear in any token; one scope per API does.** The two are different grains and the distinction is load-bearing.

A Cognito access token carries one OAuth scope per API it may reach — `cfx-customer/transaction`, `cfx-customer/deposit`, and so on. A token with no scope for an API is refused before that API's Lambda is invoked, which is the thing today's credential pair cannot express at all: one key reaches every API.

**The grain is the API, not the route and not the HTTP verb.** A scope admits or refuses a whole API; it does not separate reading a deposit from creating one. Two reasons, and the second is the one that decides it:

- `proxyAll` collapses an API onto a single `ANY {proxy+}` method, so the deployment shape offers no per-verb seam without enumerating every route.
- **HTTP method is not a permission model.** `GET` against `POST` is a transport convention, and promoting it to an authorization boundary makes the security posture a function of REST style: a search that takes a body, or a mutation exposed as a `GET`, silently lands on the wrong side, and a routing change silently re-permissions a caller. A boundary that moves when someone reshapes a route is not a boundary.

Anything finer than the API — per route, per verb, per field, per rail — is a permission, and permissions stay in-process. The DB-backed library the dashboards use runs there, with caching, and remains the only authority on what a principal may do _within_ an API. Enforcement of permissions deliberately does not move to the gateway: a path-to-permission map there would drift the same way the RFC's webhook allowlist might. An eleven-entry API-to-scope map does not, because it has one entry per deployed stack rather than one per route.

**The returned policy must name the API's resource ARNs, never a wildcard.** A `Resource: "*"` Allow admits every API the moment it is cached, collapsing the scope check to nothing. The combined authorizer runs without gateway result caching today, so this is latent rather than live — which is exactly why it needs writing down.

### The catalog

**The scope catalog is the usage-plan set.** Every keyed API declares exactly one plan, identified by a slug that is also the `apiStage` tag and the value stored in `customer_api_plan.api_stage` ([`apiUsagePlan.ts:20-34`](../../packages/pulumi-templates/src/apiUsagePlan.ts)). A scope **is** that slug, so the mapping is identity and there is no translation layer to drift.

That matters more than it looks, because the slug is deliberately not derivable from the package name or the base path — `apiUsagePlan.ts:28-33` says so explicitly, and two of them diverge today: `withdrawal-api` meters as `withdraw`, `virtual-account-api` as `account`. Any scheme that computed a scope name from a package would have been wrong for those two and silently right for the rest.

Twelve plans, twelve scopes. Route counts come from an audit of the interface packages, 2026-09-08, and are context rather than part of the model:

| Package                      | Plan slug (`api_stage`)  | Routes                                  | Scope                                 |
| ---------------------------- | ------------------------ | --------------------------------------- | ------------------------------------- |
| `deposit-api`                | `deposit`                | 9 GET, 3 POST                           | `cfx-customer/deposit`                |
| `withdrawal-api`             | **`withdraw`**           | 9 GET, 6 POST                           | `cfx-customer/withdraw`               |
| `identity-api`               | `identity`               | 7 GET, 6 POST, 2 PATCH, 1 PUT, 3 DELETE | `cfx-customer/identity`               |
| `organization-api`           | `organization`           | 8 GET, 6 POST, 2 PATCH, 1 PUT, 3 DELETE | `cfx-customer/organization`           |
| `escrow-transfer-api`        | `escrow-transfer`        | 4 GET, 10 POST                          | `cfx-customer/escrow-transfer`        |
| `reward-api`                 | `reward`                 | 5 GET, 3 POST                           | `cfx-customer/reward`                 |
| `swap-api`                   | `swap`                   | 3 GET, 3 POST                           | `cfx-customer/swap`                   |
| `virtual-account-api`        | **`account`**            | 2 GET, 1 POST, 1 PATCH, 1 DELETE        | `cfx-customer/account`                |
| `transaction-api`            | `transaction`            | 1 GET                                   | `cfx-customer/transaction`            |
| `ledger-account-api`         | `ledger-account`         | 2 GET                                   | `cfx-customer/ledger-account`         |
| `cash-deposit-simulator-api` | `cash-deposit-simulator` | 3 POST                                  | `cfx-customer/cash-deposit-simulator` |
| `card-api`                   | `card`                   | — (one Girasol webhook, `auth: false`)  | `cfx-customer/card`                   |

Three results worth carrying forward:

- **`card-api` has a plan but no customer-facing routes.** Its only HTTP route is the Girasol webhook consumer, authenticating by body signature on `auth: false`. The `card` plan and scope exist and grant access to nothing. Harmless, but it should be confirmed as an unfinished API rather than an accident.
- **`bai2-api` sets `apiKeyRequired: true` and has no usage plan at all** ([`bai2-api/pulumi/index.ts:42`](../../packages/bai2-api/pulumi/index.ts); no `UsagePlan` in its stack, and no other stack lists its API). By the rule at `apiUsagePlan.ts:20-21` that makes it unreachable by any dashboard-issued key — the precise failure that comment was written about. It sits outside the customer scope model, so it does not block this design, but it wants checking on its own.
- The catalog tracks the plan set, so it changes when an API is added or retired — not when a route or a verb changes, which is the point.

Scopes do not carry tenancy, and cannot. A scope is a static string minted into a token; tenancy is per-row. `customerId` stays a query filter — [`transactionController.ts:78`](../../packages/transaction-api/src/controllers/transactionController.ts) passing it into `transactionQueryService.search` is the shape every API already uses — sourced from the authorizer context instead of the API-key lookup. A handler that drops the filter leaks across tenants and no scope catches it, so the cross-tenant negative test belongs on every authed route.

### Hosted checkout widgets

Public OAuth clients using authorization code + PKCE against Cognito's hosted UI. Widget end-users sign in with phone OTP — the login UX MoveMoney uses today, kept and moved into the pool. The consent step mints an entity-scoped token (the fourth kind): `entity_id` bound, the widget's minimal role, so the token is entity-scoped before it reaches any API.

## Sequencing

### Rail A (parallel, in flight): the API authorizer rollout

The authorizer RFC owns centralizing the API-key check: Rust toolchain, pilot on `cash-deposit-simulator-api`, roll to the 11 APIs, retire the in-process plugin (ENG-3053). Independent of Cognito. This proposal's phases depend on it landing, not on its sequencing. The one prior special case is gone: the Yellowcard webhook moved from `withdrawal-api` into the backoffice sink, where it authenticates by Hookdeck signature with `auth: false` like every other partner route, so retiring the plugin no longer drops a signature route's layer.

### Phase 1 — Customer pool + authorizer Cognito source + dashboard cutover

The heaviest phase. Cognito user pool (phone-OTP passwordless, passkey 2FA), Pre-Token Generation Lambda, and the authorizer's second identity source land together. The customer dashboard flips to Cognito login and to DB-backed permissions (the admin RBAC pattern ported across the `lib/auth.ts` seam — the exported names match, but the port must carry multi-workspace resolution and the customer-specific surface the admin file lacks, ENG-3070) in the same cutover. Users and workspaces (PropelAuth orgs = customers) provision into Cognito and the RBAC tables; no passwords migrate (ENG-3067).

- Dual-accept window (~2–4 weeks): existing PropelAuth sessions stay valid; all new sessions come from Cognito (ENG-3068).
- Staged cohorts: CFX staff → early-access customers → everyone.
- Multi-workspace switching is preserved as-is: users keep logging in once and switching between the customers they belong to, the switcher re-authing into the target customer's claim.
- Passkey enrollment opens with the cutover (dashboard users; MoveMoney users as Phase 3 lands them in the pool).
- Rollback: re-enable PropelAuth login routes. No sessions invalidated, instant, lossless.

> **Status: dashboard cutover done (2026-10-02); the authorizer's Cognito source not started.** customer-dashboard signs in through `cfx-platform` in dev and prod (`auth.dev.cfx.to` in #4137, `auth.cfx.to` in #4185) and resolves permissions from `b2b.customer_dashboard_*` per request ([SHARED_DASHBOARD_AUTH.md](SHARED_DASHBOARD_AUTH.md)). Existing users were provisioned into the pool by a one-off backfill in `backoffice-api`, deleted once nobody eligible was left; no passwords migrated. The dual-accept window (#4114) is closed: the PropelAuth fall-through in `proxy.ts` and the `SIGN_IN_PROVIDER` switch are removed, Cognito is unconditional and its `COGNITO_*` config is required, so the rollback above no longer exists. Two things were built differently from the paragraph above: sign-in is a password plus TOTP or a passkey, with no phone OTP and no SMS, and the pool has a pre sign-up Lambda and no Pre-Token Generation Lambda, because permissions are read from Postgres per request rather than minted into the token ([packages/cognito/README.md](../../packages/cognito/README.md)).

### Phase 2 — Decommission PropelAuth

With the dashboard cutover complete, PropelAuth has no remaining _login_ consumer (external API callers use the credential pair, not PropelAuth) — but not yet no consumer at all: `admin-dashboard` and `internal-dashboard` use PropelAuth's admin API (`PropelAuthClient`) to provision customer workspaces. That provisioning moves to Cognito plus RBAC writes first. Then: revoke webhooks, remove the `propelauth-events` route, drop the client package, cancel the vendor.

> **Status: done in the repo (2026-10-02); the teardown outside it is pending.** No code calls PropelAuth. Removed: the `@propelauth/nextjs` SDK and its patch, the in-repo `PropelAuthClient`, the dashboards' `/api/auth/[slug]` routes, the webhook consumer (`POST /v1/backoffice/events/propelauth`) and its Hookdeck handler, the PropelAuth backfill scripts, the `createPropelAuthOrganization` onboarding activity, and every `PROPELAUTH_*` env key. What replaced the vendor's jobs:
>
> - **Provisioning.** Onboarding's `createCustomer` activity mints the workspace id, a UUID, instead of creating a PropelAuth org.
> - **Invitations.** An invite is a row (`inv_…`) accepted at Cognito sign-up or sign-in, and our own services send its email, which PropelAuth used to send.
> - **Team pages.** They no longer show a member's MFA state or last-active time: both were live PropelAuth reads.
>
> Done outside the repo (2026-10-07): every `PROPELAUTH_*`, `SIGN_IN_PROVIDER`, `NEXT_PUBLIC_AUTH_URL` and `CF_ACCESS_TEAM_DOMAIN` key is deleted from every config of Doppler `cfx-customer-dashboard`, `cfx-bank-dashboard`, `cfx-admin-dashboard`, `cfx-common`, `cfx-backoffice-api` and `cfx-temporal-worker`, and `PROPELAUTH_WEBHOOK_SECRET_KEY` from `cfx-hookdeck`. The orphaned prod Hookdeck source, connection and destination (`src_8g11xc4dl5ljm7`, `web_M6kXz4jGUVAk`, `des_MalLqWB22La0`) are deleted.
>
> Still to do outside the repo, in this order:
>
> - Delete `auth.cfxlabs.com` (a CNAME to `ext.propelauth.com`) and `_propelauth_verification.cfxlabs.com` from `packages/aws-network/terraform.tfvars`. Both are in Terraform state, so the `network` apply destroys them. Cancelling PropelAuth first would leave the CNAME dangling, open to a subdomain takeover.
> - Delete the `_propelauth_verification.cfx.to` and `_propelauth_verification.dev.cfx.to` TXT records in Cloudflare.
> - Revoke the API keys and webhooks in PropelAuth, then cancel the account.
>
> **Legacy cleanup (2026-10-03, columns dropped 2026-10-07): no code reads a PropelAuth identifier and the database holds none.** A second change removed the last readers and the `propelauth-user` Hookdeck source's declaration:
>
> - **Workspace key.** `b2b.customer.propelauth_workspace_id` was replaced by `workspace_external_id`, with the same values, and a trigger kept the two equal while older code was still deployed. The key services call `workspaceId` now lives on `b2b.customer_dashboard_workspace.external_id`; both customer columns and the trigger are dropped ([DASHBOARD_WORKSPACE_TABLES.md](DASHBOARD_WORKSPACE_TABLES.md)).
> - **Historical ids.** PropelAuth user ids stored in JSONB were rewritten to dashboard user ids (`cdu_…` / `bdu_…`): a transaction's creator (`createdByDashboardUserId`), a terms acceptance (`acceptedByDashboardUserId`), the member a team activity row is about (`memberDashboardUserId`) and a customer's notification `userIds`. An id matching no user is dropped with its key.
> - **Migrations.** `20261003170000` to `20261003170006` in `packages/db/migrations`. The Drizzle schema declares no `propelauth_*` column.
>
> **Column drop (2026-10-07).** `20261010040000_drop_legacy_auth_columns.sql` drops `b2b.customer.propelauth_workspace_id` and `workspace_external_id`, `b2b.bank.propelauth_org_id`, `propelauth_id` on the customer and bank dashboard user tables, `propelauth_invite_id` on the two invite tables, the trigger `customer_workspace_external_id_sync` and its function. `admin.dashboard_user.cf_access_sub` dropped one deploy later, in `20261010050000_drop_admin_dashboard_user_cf_access_sub.sql`, once the Drizzle schema without it was live. The down block re-adds the columns empty; the dropped values are not restorable.

### Phase 3 — Scoped API tokens + MoveMoney migration

Machine principals join the customer RBAC; the four token kinds land with token-management pages; the authorizer resolves all four kinds to the same principal context. Existing keys migrate to "entire customer" tokens; new tokens can be user-delegated, service-account, or entity-scoped.

MoveMoney migrates onto the model: its users authenticate against the `cfx-platform` pool with the same phone-OTP login UX they have today; the app mints or receives entity-scoped tokens instead of deriving scoping in-app; the static app-wide `COINFX_API_KEY`/`COINFX_API_SECRET` pair retires into the scoped model. MoveMoney's session internals (iron-session cookie, self-verified JWT, bespoke permission derivation) are replaced, not ported.

Two MoveMoney-specific costs to price:

- **User provisioning.** ENG-3067 covers PropelAuth users only; MoveMoney users are phone-keyed per-brand rows (1:1 with b2b identities). Phase 3 provisions them into the pool with `customer_id` from the brand's customer.
- **The WhatsApp OTP channel.** MoveMoney's Twilio Verify sends SMS or WhatsApp; Cognito's native OTP sends via Amazon SNS — SMS only. Either the WhatsApp channel is dropped (a UX change, not a port) or custom auth rides a Twilio sender, which takes the flow out of the hosted UI.

### Phase 4 — Bank pool + bank-dashboard cutover

Same shape as Phase 1, smaller blast radius, plus one capability add: bank staff get the multi-bank switching that customer-dashboard users have for workspaces. Today a staff member's bank scope comes from `getAuthBankIds()` (permission strings plus host resolution); after the cutover they sign in once and switch between the banks they can reach, the token carrying the bank in play. Switching redirects to the target bank's canonical URL — each bank serves from its own host, the MoveMoney pattern (`dart.cfx.to` hosts one brand, tenant derived from the host), which canonicalizes today's host-based bank resolution. The bank dashboard also flips to DB-backed permissions in the same cutover — the Phase 1 port applied to the bank domain, carrying the multi-bank scope axis the admin pattern lacks (ENG-3070). Bank staff enroll passkeys at cutover. Issues: ENG-3052 (bank sign-in/sign-up).

> **Status: done (2026-10-02).** bank-dashboard signs in through `cfx-bank` on `auth.bank[.dev].cfx.to` in dev and prod, flipped together with customer-dashboard, and resolves permissions from `bank.bank_dashboard_*` per request. The pool is admin-create only, so bank users arrive by invitation or tenant SSO. Each bank's own host is registered on the bank app client when the bank is saved in admin-dashboard ([packages/cognito/README.md](../../packages/cognito/README.md#custom-domains)). `b2b.bank.propelauth_org_id` was dropped on 2026-10-07 with the other columns listed under Phase 2. Since 2026-10-03 the bank in play comes from the caller's memberships, one active bank per session, and not from the host ([DASHBOARD_WORKSPACE_TABLES.md](DASHBOARD_WORKSPACE_TABLES.md)).

### Phase 5 — Admin pool + admin cutover

Identity swap only. `admin.dashboard_user` re-keys from `cf_access_sub` to Cognito `sub` (additive migration, backfilled on first login). Google Workspace federation preserves the current login UX; Cloudflare Access is retired for this app. Roles and permissions tables are untouched.

> **Status: done (2026-10-02).** Flipped in dev and prod with the same prod deploy that moved the customer and bank dashboards, which also deleted the Cloudflare Access application. Operators sign in through `cfx-admin` on `auth.admin[.dev].cfx.to`; `/auth/login` goes straight to the `GoogleWorkspace` IdP, and `getAuthUser` verifies the shared Cognito session and refuses non-`cfx.to` addresses (the tokens are now held server-side behind one id cookie, and the proxy checks only that the cookie exists: [AUTH.md § Session store](../AUTH.md#session-store)). `admin.dashboard_user` gained a unique `cognito_sub`; `cf_access_sub` is dropped (`20261010050000_drop_admin_dashboard_user_cf_access_sub.sql`). The first Cognito sign-in links the one unlinked row with a matching email, so the operator keeps their role. `sst.config.ts` no longer creates the `ZeroTrustAccessApplication` or the `CF_ACCESS_*` env; the `admin[.dev].cfx.to` record stayed Cloudflare-proxied until #4246 turned it grey. The five `COGNITO_ADMIN*` values are set in Doppler `cfx-admin-dashboard` dev and prod. Details in [AUTH.md § Identity flow](../AUTH.md#identity-flow).

### Phase 6 — Checkout widgets

PKCE clients on the proven plane, minting entity-scoped tokens at consent. Only Phases 1 and 3 are prerequisites; if product pressure appears, this phase can interleave earlier.

### Phase 7 — The dashboards and internal callers become API clients

The dashboards stop importing services and call the APIs with the signed-in user's own token, progressively, page by page. The TypeScript islands that survive the Rust port (Slack bots, email rendering) are API clients too. The plan is [RUST_SERVICE.md § 1](../RUST_SERVICE.md#1-scope); this phase owns its auth half:

- **A dashboard call carries the user's Cognito access token**, read server-side from the session store that already holds it ([AUTH.md § Session store](../AUTH.md#session-store)). The browser keeps only the session cookie. The authorizer resolves the token to the same principal context a key gets. The API checks permissions in-process against the same RBAC tables the dashboard reads today, so moving a page moves no authorization decision.
- **Each pool reaches its own surface**, by the issuer-to-surface mapping above. `cfx-platform` tokens reach the customer `/v2` APIs, `cfx-bank` tokens the bank APIs, and `cfx-admin` tokens the admin APIs. The bank and admin surfaces are new. They carry what the dashboards do today that no customer API serves.
- **The admin pool needs machine principals.** The four token kinds are customer-domain. `internal-slack-bot` and the event-driven Slack feeds act for CFX across customers, so they need a client-credentials app client on `cfx-admin` holding an admin role. The other option is IAM-authorized private routes. This is open question 9.
- **The customer Slack bot acts as the linked user**, through a user-delegated token (Phase 3), so it can do nothing the user could not do in the dashboard.

Prerequisites: the authorizer's Cognito source (Phase 1's undone half), user-delegated tokens (Phase 3) for the customer Slack bot, and `/v2` routes for each page's reads and actions.

> **Status: proposed (2026-10-11).**

## Data model

- Three RBAC table sets following the admin pattern (`dashboard_user`, role, permission, join, activity) for the customer, bank, and admin domains, keyed by Cognito `sub`.
- Machine principals in the customer RBAC: an `api_token` table with kind (`entire_customer` \| `user_delegated` \| `service_account` \| `entity_scoped`), nullable user reference for delegated tokens, role assignment, nullable entity scope (mandatory for the entity-scoped kind), credential hash, status, audit columns. Legacy rows provision from today's `b2b.customer_api_key` (read via `CustomerApiKeyAdminQueryService`; the table carries no scope or entity column, so every legacy row maps 1:1 to the entire-customer kind, holding the full scope set).
- The Pre-Token Generation Lambda reads these at issuance with a short-lived cache. This is the one deliberate DB coupling on the token-issuance hot path; the cache is the mitigation. Trigger **version three** is required — v1/v2 cannot modify access tokens for machine identities, and the service-account kind (client credentials) needs exactly that.

## Verification

- Authorizer contract tests per credential form and per token kind, plus negatives: cross-pool token on the wrong surface, revoked credential, user-delegated token after role change, entity-scoped token with no entity binding, token missing `customer_id`, and a headerless bearer request reaching the authorizer (the gateway must not pre-reject on missing credential headers).
- Scope negatives: a token carrying no scope for the API it calls, and a token carrying a neighbouring API's scope, are both refused before the Lambda is invoked. Assert the invocation count, not just the status code — a 403 produced inside the handler would pass a status-only test while proving nothing.
- **Two different APIs in succession against the same authorizer instance.** A token scoped to one API calls it, then calls a second API it has no scope for. The second call is what catches a wildcard resource ARN in the returned policy — that bug passes every single-request test and only appears once a policy is reused.
- Cross-tenant negative on every authed route: a valid, correctly scoped token whose `customer_id` differs returns no rows. This is what catches a handler that lost its tenant filter, and it is per route rather than once.
- Service-level integration tests mirroring the existing auth tests; the RFC's dual-read assertion that credential headers still reach the handler.
- The RFC's revocation drill (rotate a key, time the window) extended to scoped tokens.
- Per-phase kill criteria: auth failure rate, 401 spike, support tickets; each held within threshold for two weeks before proceeding.

## Risks

| Risk                                                                                                              | Mitigation                                                                             |
| ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Pre-Token Generation adds a DB read at issuance                                                                   | Short-lived cache; monitor token-issuance p99                                          |
| User-delegated tokens inherit live permissions: a role change silently re-scopes outstanding tokens               | Intended, but surfaced in the token-management UI so operators can see effective scope |
| Entity-scoped tokens concentrate checkout authority in a narrow scope; leaked ones are potent within their entity | Short TTLs; the revocation drill applies doubly                                        |
| Revocation window widens under the RFC's gateway result cache                                                     | All token kinds inherit whatever revocation budget is signed off (RFC open question 1) |
| Lost passkey locks an enrolled user out of passkey-first sign-in (OTP-first and required MFA are incompatible)    | Phone-OTP fallback stays enabled as recovery; re-enroll at next OTP login              |
| Choice-based auth and passkeys need the Essentials/Plus feature plan; SMS needs account-level activation          | Feature-plan cost per pool and SMS activation before Phase 1                           |
| OTP volume (phone via Amazon SNS, email fallback via SES)                                                         | Rate-limit and deliverability checks before Phase 1                                    |
| Hosted UI branding per pool                                                                                       | Design input needed before Phase 1                                                     |

## Non-goals

- Folding usage-plan API keys into the authorizer (the RFC's open question 3; out of scope here too).
- Keeping Cloudflare Access in front of the admin dashboard (retired for that app at Phase 5).
- Permission claims in any token, in any pool.
- A single shared pool across audiences.
- Porting MoveMoney's session internals; its auth plane is replaced, its OTP login UX is kept.

## Open questions

1. Revocation budget (RFC open question 1) needs a sign-off; all token kinds extend its blast radius.
2. Token TTLs and refresh policy per pool and per token kind.
3. Live inheritance for user-delegated tokens (proposed) versus snapshot-at-mint.
4. SMS/OTP capacity planning (Amazon SNS for phone, SES for email fallback).
5. Hosted UI branding per pool.
6. Passkey policy per audience: required for bank staff, opt-in for customers? Enrollment prompted at first login or deferred?
7. **Is `card-api`'s empty customer surface intentional?** It carries an auth strategy, a usage plan and a scope, but exposes only a signature-authenticated webhook. Either it is finished and the plan is vestigial, or routes are pending.
8. **`bai2-api` requires an API key but belongs to no usage plan**, which by `apiUsagePlan.ts:20-21` makes it unreachable by any dashboard-issued key. Outside this design's scope, but it should be confirmed as deliberate or fixed.
9. **CFX-internal machine principals (Phase 7)**: a client-credentials app client on `cfx-admin` with an admin role, or IAM-authorized private routes. The first keeps one authorizer path for every caller. The second needs no token minting, but adds a second auth mechanism the authorizer does not see.

### Resolved 2026-09-08

- Scopes are per API, not per route or HTTP verb. Method is a transport convention and makes a poor authorization boundary.
- **A scope is a usage-plan slug**, and a migrated credential's scope set is its customer's live `b2b.customer_api_plan` grants. This re-expresses a boundary API Gateway already enforces rather than inventing one, so it is behaviour-preserving by construction.
- Deriving scope grants from historical access logs was considered and rejected: it lets a caller's past traffic define its future permissions, cannot see an integration that has not run yet, and encodes accidents as policy. The plan grants make it unnecessary — the entitlement is declared, not inferred.
- Granting every scope to every migrated key was also considered and rejected once the plan grants were found; it would have been strictly more permissive than today.
