Security
Security
Section titled “Security”Last updated: 2026-08-23 — ADR-16 changed @finstack/ops-mcp’s D1 trust boundary (see Trust Boundaries): 25 logical database names now resolve to only 10 physical databases, so a name no longer implies an isolated database. Prior: 2026-07-29 — new @finstack/ops-mcp operator D1-access surface (local stdio, not deployed — see Trust Boundaries). Prior: 2026-07-25 — ADR-9: finstack-rs (Rust/Postgres) deleted; rewritten to describe finstack’s actual current mechanisms, verified against source rather than carried over from the deleted Rust implementation. Where a mechanism could not be confirmed present, it’s marked as such rather than assumed.
Auth & Identity
Section titled “Auth & Identity”API callers: argon2id-hashed sk_* keys, verified via svc-auth/src/api-keys.ts (argon2id WASM module, statically-precompiled for the Workers bundler — hash-wasm’s runtime-compile approach is incompatible with the Workers sandbox). Comparison via timingSafeEqual.
MFA (TOTP): mounted at /mfa/* in svc-auth (RFC 6238-style, 30s window, 5-min single-use challenge tokens — svc-auth/src/mfa.ts). No evidence of a step-up gate on high-value routes (the Rust backend’s require_mfa_token middleware pattern, forcing X-MFA-Token on routes like API-key revoke, was not found anywhere in finstack on grep) — MFA currently exists as a standalone enroll/verify flow, not yet wired as a gate in front of other Workers’ routes.
OAuth: mounted at /oauth/* in svc-auth.
Dashboard users — broken. svc-auth has passkey ceremony logic (ceremonies.ts) but it’s never mounted in index.ts — no /passkeys/* route exists. Dashboard login has no working auth path.
Inter-Worker trust: every domain Worker (payment, payout, etc.) authenticates callers via a signed assertion from svc-auth, verified with the shared INTERNAL_ASSERTION_SECRET (verifyAssertion()) — routes derive tenantId solely from the verified assertion’s claims, never from a caller-supplied header. Confirmed via payment/src/index.ts’s own comment: “trusts a caller-supplied tenantId — every route derives it solely from the signed assertion.”
Constant-time compares: timingSafeEqual used in svc-auth for API key and MFA token comparisons.
Tenant Isolation
Section titled “Tenant Isolation”No database-level RLS — D1/SQLite has no equivalent to Postgres Row-Level Security. Isolation is entirely assertion-based: svc-auth issues the signed assertion carrying tenantId; each Worker scopes its D1 queries via TenantScopedDb(env.DB, tenantId) (seen in payment/src/index.ts and presumably every other domain Worker — not individually re-verified per Worker here). This is a materially different isolation model than the deleted Rust backend’s Postgres RLS, not a like-for-like port — a bug in a single Worker’s TenantScopedDb usage has no database-level backstop the way a missing WHERE tenant_id = $1 did under RLS.
Trust Boundaries
Section titled “Trust Boundaries”- No CF Worker gateway proxies to an internal-only backend anymore —
gatewaydispatches directly to domain Workers via CF service bindings (no network hop, no bearer-token-over-HTTP step to secure). - No
/admin/*HTTP surface exists. It only ever existed via the now-deletedfinstack-admin-gateway→ Rustapi-admin— see CLAUDE.md’s functional-gaps list. - Operator-privileged D1 access exists, but as a local process, not an HTTP surface (2026-07-29).
@finstack/ops-mcp(packages/finstack-ops-mcp, see COMPONENTS.md/INTERFACES.md) reads across 25 logical FinStack database names via the Cloudflare D1 HTTP API — as of 2026-08-23 (ADR-16), those 25 names resolve to only 10 physical databases, since 15 non-critical Workers share a critical Worker’s database by theme. This narrows the isolation a database name implies, not just its count: a query scoped by a logical name (e.g.tax-db) can read any table in the physical database it shares (e.g.payment-db’spaymentstable) —ops_db_queryvalidates the database name, never which tables belong to which Worker. Schema-introspection queries (SELECT name FROM sqlite_master,PRAGMA table_list) against any of the 15 aliased names likewise surface every co-located Worker’s tables, not just the named one’s. Every individual table stays collision-free (verified at consolidation time, see DECISIONS.md’s ADR-16), so this widens what one query can see, not what it can silently corrupt across Workers — but it is a real narrowing of the isolation this tool previously had, and the same privilege-boundary reasoning below (possession ofCF_API_TOKEN) is what actually bounds it, same as before ADR-16. Deliberately not a deployed Worker: it runs only as a local stdio process launched by Claude Code.CF_API_TOKENis a static value in~/.claude.json’s MCP server config (the vault is where it was generated/is recorded, not a runtime fetch source), never committed to this repo — so the process has no internet-reachable listener at all: no hostname to secure, no WAF/allowlist to maintain, and no repeat of thefinstack-admin-gatewayincident class (an unauthenticated, auto-token-injecting admin Worker with no IP allowlist, deleted 2026-07-26 — see BUGS.md). Read-only by construction (ops_db_queryrejects anything but a single SELECT/PRAGMA before ever calling the D1 API) and redacts columns matchingsecret/credential/password/hash(substring) orcode/evidence(exact name only — narrowed after over-redacting real columns likepayouts.return_code, seedb.ts) in every returned row, mitigating (not closing — see below) the same bulk-exposure class ADR-12’s Task 6 review caught incompliance’s list endpoints. Scope is deliberate and limited, and bypassable by design, not by oversight: this is a key-name filter over the returned row shape, not a query-plan analysis. Aliasing a sensitive column to an innocuous name (with or withoutAS, or wrapped in an expression likesubstr()) defeats it, and aWHERE-clause predicate can exfiltrate a value blindly without it ever appearing in the response at all — no output-side filter can stop either. AnAS-aliasing regex guard was tried and removed (PR #516’s second review round: it caught only the bare-column form while both false-negativing on expression-wrapped columns and false-positiving on ordinary table aliases likeFROM x AS t). The real privilege boundary here is possession ofCF_API_TOKENitself — whoever holds it already has unmediated D1 access outside this tool (a rawcurlto the same D1 HTTP endpoint bypasses redaction completely), so this filter’s actual job is catching accidental exposure (an operator or LLM doingSELECT *without noticing a secret column), not defending against deliberate extraction by someone who already holds the credential. Scope is also limited to credential/secret-class columns, not general PII (email/phone/address are left visible — routine for most diagnostic queries). See DECISIONS.md’s ADR-13 and INTERFACES.md for the full list and rationale. - Stripe webhooks: HMAC-SHA256 validated directly by the
paymentWorker’swebhook-receiver.ts(verifyWebhookSignature) — no separate IP-allowlisting gateway layer exists anymore (the oldfinstack-webhooks-gateway’s KV-based Stripe IP allowlist was deleted along with it). - AI Worker (
ai) — task input crosses the trust boundary to Neureus: task input sent to theaiWorker is transmitted to Neureus (and downstream OpenAI/Anthropic/Workers AI depending on model). No redaction/classification of input data; tenants are responsible for not including regulated PII in task inputs. (Carried over unchanged from the deleted Rust implementation — same integration, same caveat.) - Moov is not integrated into
finstackat all (deferred scope — seedocs/cf-rewrite-deferred-scope.md). The prior Moov webhook-signing trust-boundary notes (signed metadata, not body) described the deleted Rust implementation and don’t apply to any currently-running code.
Rate Limiting
Section titled “Rate Limiting”No rate limiting found in gateway or svc-auth on grep (rate.limit/RateLimit — no matches). Same gap as the deleted Rust backend had (FinstackError::RateLimited type defined, no layer wired) — not a regression introduced by the removal, just not yet re-verified as fixed either.
Edge layer: CF WAF DDoS protection active on the finstack.sh zone (Galactic account). The Rust-era pending WAF rate-limit rule for /api/passkeys/* is moot — that route doesn’t exist on the CF side yet.
Security Headers
Section titled “Security Headers”Set by the gateway Worker (workers/gateway/src/index.ts): X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin. Dashboard (app.finstack.sh) headers via finstack-dashboard/public/_headers — not re-verified in this pass.
Encryption at Rest
Section titled “Encryption at Rest”routing’sprocessor_configs.credentials(payment/payout processor API keys, e.g. Stripe secret keys) is AES-256-GCM encrypted (workers/routing/src/crypto.ts), keyed by theROUTING_CREDENTIALS_MASTER_KEYWorker secret. Previously stored as plaintext JSON — deliberately deferred (per the migration’s own comment) until a real processor adapter existed to decrypt it; ADR-12’s Stripe Connect payout rail is that adapter.routingis the only Worker that ever decrypts a credential; every other Worker only ever sees the metadata-only view (toView()excludescredentialsfrom every response, unchanged by this).recipient’srecipient_bank_accounts.account_number_encis AES-256-GCM encrypted the same way (workers/recipient/src/crypto.ts), keyed byRECIPIENT_BANK_MASTER_KEY— write-only today (no decrypt path exists, matches the pre-CF-rewrite Rust behavior).
Secrets Management
Section titled “Secrets Management”- All secrets are Cloudflare Worker secrets (
wrangler secret put), set per-Worker — no central secret store. Fly.io secrets no longer apply (Fly infra untouched but no longer deployed to from this repo). - Never committed to git —
.cf-token,.env,*.key,*.pemin.gitignore VENDORS.md,ENVIRONMENTS.md,SECURITY.mdreference env var names only, never values
Input Validation
Section titled “Input Validation”- Request bodies: parsed via each Worker’s own JSON parsing + validation (no shared framework equivalent to Rust’s
axum::Json<T>+ serdedeny_unknown_fieldswas verified in this pass — validate per-Worker, don’t assume a repo-wide pattern). - SQL: D1 queries should be parameterized (
env.DB.prepare(...).bind(...)) — not exhaustively re-audited across all ~30 Workers in this pass. databaseWorker (Post AI proxy): deferred, not live (POST_AI_API_KEYunvaulted) — the Rust backend’svalidate_path_segment()allowlist pattern should be re-verified as ported before this Worker goes live, not assumed.
Compliance
Section titled “Compliance”PCI scope: FinStack is a payment platform. Stripe handles card data (PCI SAQ A). FinStack stores payment intent IDs and amounts only — no PANs, CVVs, or card holder data.
Identity PII in compliance_kyc_checks.evidence (ADR-12 Task 6, 2026-07-28): on a Stripe Identity verified webhook, compliance persists the recipient’s verified name/DOB/address into evidence.verifiedOutputs — plaintext D1, not encrypted at rest (unlike routing’s processor credentials or recipient’s bank account numbers above). SSN/passport numbers are deliberately not mapped or stored. Single-record reads (GET /compliance/kyc/:id, and the response from /review//decide) include it; the two list-shaped reads (GET /compliance/kyc/history, GET /compliance/kyc/stale) redact it — both would otherwise return verified PII for every matching row in the tenant in one call, gated only on the general compliance:read scope. Revisit if a use case needs verifiedOutputs in a list response — that would need a narrower scope, not just re-adding the field.
OFAC/sanctions screening — compliance’s own screening is a stubbed no-op; the money-moving processors screen instead (clarified 2026-07-28, user). compliance’s in-house sanctions-screening path (the Rust SanctionsScreener over the Treasury SDN list) was never ported to finstack and is a documented, intentional no-op (workers/compliance/src/index.ts’s NOTE comment; docs/cf-rewrite-deferred-scope.md). That’s a real gap in FinStack’s own independent screening — but it is not the same as “no screening happens anywhere in the money-movement path.” Every rail that actually moves real funds routes through a regulated processor with its own sanctions-compliance obligations:
- Recipient/payout rail (ADR-12, Stripe Connect Custom): Custom Connect account creation requires the platform to submit identity/verification data via API (
POST /v1/accounts,POST /v1/accounts/{id}/persons) — Stripe underwrites and screens that submitted data, including against sanctions lists, as part of its own account-approval process before enabling payouts capability. A recipient who fails Stripe’s own screening does not get a functioning Connect account, independent of anythingcompliancedoes or doesn’t check. - Wallet topup / checkout charges (Stripe
PaymentIntent): processed through Stripe’s own card-network and Radar compliance systems, which screen cardholders/counterparties as part of Stripe’s standard payment-processing compliance obligations. - Moov (not yet integrated — zero rows in
processor_configs— but named in ADR-12 as the second rail): Moov’s own KYC/AML onboarding for ACH/RTP counterparties includes sanctions screening as part of its compliance program, same pattern.
What this does and doesn’t close: it substantially reduces (not eliminates) the exposure the “hard blocker” framing in DECISIONS.md’s ADR-12 and the original docs/cf-rewrite-deferred-scope.md entry assumed, since neither accounted for processor-side screening. It does not mean FinStack has zero remaining exposure or that this repo’s own compliance posture is fully settled — whether relying on a processor’s screening (rather than running an independent, redundant check) satisfies FinStack’s own regulatory obligations as the platform sitting between the tenant and the processor is a policy question for whoever owns FinStack’s regulatory posture, not something this correction settles unilaterally. Treat compliance’s stub as a known gap in defense-in-depth, not an open door.
Tap2 Wallet launch implication: given the above, Tap2 Wallet’s launch (docs/superpowers/plans/2026-07-28-tap2-wallet-production-launch.md) is not relying on zero screening — its wallet-topup path is Stripe-charge-screened today, and its seller-payout path (gated on ADR-12 Task 7) will be Stripe-Connect-screened once live. No monitored-launch ceiling is being tracked for this specific item as a result; if whoever owns regulatory posture wants FinStack to run its own independent screening in addition to relying on processor screening, that’s a forward-looking build decision (see docs/cf-rewrite-deferred-scope.md’s OFAC row), not a pre-launch blocker.
What “real screening” would close this via: a sanctions-list API integration gating compliance’s KYC approval, or gating recipient/payout creation, for the affected subject. Not built as part of this launch.