Why this exists
Cultural nonprofits — theaters, museums, dance companies, music ensembles, arts councils — run on margins that don’t survive enterprise software pricing. A typical UCA member organization has 1–10 staff, an annual budget in the low hundreds of thousands, and an IT line item that rounds to zero. They need member databases, ticketing, donor portals, email marketing, payment processing, and increasingly the kind of identity and access management that any organization handling payment information or personal data is expected to have. They cannot afford it.
The conventional answers all fail in specific ways. Per-org SaaS (each organization buys its own Auth0/Okta/Salesforce/etc.) multiplies costs across 650 orgs and produces 650 separate logins for any patron who engages with more than one. One-off custom builds by volunteer developers produce 650 incompatible systems, each with its own security posture and its own bus factor of one. Doing without means staff sharing passwords on sticky notes, member data living in spreadsheets, and donations processed through whatever happens to be cheapest this quarter.
Ensemble is a fourth option: one platform, deployed once, serving the cooperative. The Utah Cultural Alliance operates it; member organizations consume it as a shared service. The economics work because the costs are amortized across 650 organizations rather than borne by each one. The technical design is built around that economic reality from the ground up.
What it does
Ensemble has three architectural layers:
- Identity federator at
api.utahculturalalliance.org— multi-tenant OAuth/OIDC, MFA, session management, role-based access. The auth backbone every companion app talks to. - Companion apps at various subdomains — Ensemble Market is the first (e-commerce / membership purchases / event registration), with ticketing, donor portal, and volunteer scheduling apps to follow. Apps never hold payment processor credentials, never talk to the CRM directly, never accumulate sensitive PII beyond what the user is actively editing.
- Federation endpoints on the IdP — companion apps call back through Ensemble for anything sensitive: payments via Stripe, member lookups via Neon CRM, email dispatch. The IdP holds the credentials; the apps hold scope tokens.
From a member’s perspective: sign in once at any UCA org, get a unified session that works across all of them. From an org admin’s perspective: members and donations show up in Neon CRM (the existing system of record) automatically. From a security perspective: the credential surface is small, hardened, audited centrally; the blast radius of any one companion app being compromised is bounded by that app’s scope.
Architectural choices
The three foundational choices are worth describing because each is unusual and each is driven by the nonprofit-budget constraint rather than typical engineering taste.
Cloudflare Workers + D1 was chosen for cost and latency. The 650+ member orgs span Utah — and increasingly, beyond — and need consistent sub-100ms auth response times whether the user is in Salt Lake City, Logan, or St. George. Workers run at every Cloudflare edge; D1 has read replicas at the edge. The math on traditional hosting (a regional VPS, a managed Postgres) gets ugly fast when the budget is a nonprofit budget. Workers are pennies; D1 is free at this scale.
SvelteKit is the application framework. The unusual choice is using it server-side as the IdP itself rather than just as a frontend. SvelteKit’s +server.ts route handlers are perfectly fine HTTP endpoints, and they integrate cleanly with the rest of the SvelteKit app (admin UI, branding pages, signup flows). Using one framework for the whole platform — backend, admin UI, and public auth pages — meant one mental model and one deploy pipeline.
D1 + SQLite replicas with write forwarding is the most interesting architectural detail. D1 is Cloudflare’s primary database, but for some workloads we run additional SQLite databases that mirror D1 and forward writes back to the primary. There are three reasons for the replicas, and they stack: read scaling for admin-heavy paths (admin dashboards run joins and aggregations that benefit from local SQLite even when D1 reads are already at the edge), local-dev parity (the same SQLite engine runs on a laptop as in production, so the test environment isn’t a different database), and disaster recovery (an AWS Amplify-hosted replica gives us read-only continuity if D1 has a regional issue, which it occasionally has). Write forwarding is a custom forwarder rather than D1’s Sessions API — every replica intercepts writes at the SvelteKit hooks layer, routes them through HTTPS back to a write endpoint on the primary, and reflects the result locally on success. The forwarder is maybe 200 lines of code, but it’s the thing that lets a read replica on AWS coexist with a primary on Cloudflare without the rest of the codebase noticing.
The shadow account problem
The hardest part of building Ensemble wasn’t the architecture; it was the migration. Neon CRM held 8,319 user accounts representing a decade of cultural-org memberships, donations, and event registrations. The members didn’t know they were going to be migrated. They had passwords (or didn’t), they had email addresses (often outdated), they had relationships to organizations (sometimes accurate). Forcing all 8,319 to re-register would have been catastrophic — and a large fraction of those members are older, technology-skeptical, and would have simply stopped engaging.
The solution was a shadow account system: every Neon record got a corresponding Ensemble account, pre-created but not activated. The first time a shadow user attempts to log in through any companion app, Ensemble matches their email against the shadow set, walks them through a one-time password-set flow, and “claims” their account. Their entire history — memberships, donations, event attendance — is already there because the shadow was seeded from Neon. From the user’s perspective, they signed up for one thing and discovered they already had an account; from the data’s perspective, the legacy ID stayed stable through the transition.
What broke. Several things, none catastrophic, all instructive. Email-match collisions: a small fraction of Neon records shared an email address with another record — usually a household account where two spouses had been entered separately, occasionally a staff member who’d been entered both as staff and as a donor. The shadow system flagged these as ambiguous and pushed them to a manual review queue rather than auto-claiming either one. Stale or bounced emails: maybe 8% of the addresses were dead, attached to accounts that hadn’t engaged in years. Those shadows still exist but will probably never be claimed; they’re not hurting anything, but they’re a long-term cleanup item. Duplicate detection: Neon had accumulated near-duplicate records over a decade — Jane Smith and Jane R. Smith and jsmith@example.com versus jrsmith@example.com referring to the same person with slightly different data. We built a fuzzy-matcher that flagged probable duplicates and queued them for org-staff review; merging is a one-way operation in Neon, so we deliberately didn’t auto-merge anything. The manual review queue handled roughly 340 ambiguous cases across the migration; the rest auto-claimed cleanly as members logged in over the following months.
The shadow system also turned out to be useful long-term, not just for migration. New members who get added via Neon (event registration, donation, membership purchase) become shadow accounts in Ensemble automatically. When they later interact with a companion app, they’re already known. The shadow system became the basic primitive of how Ensemble and Neon stay synchronized — not a one-time migration tool but a permanent part of the architecture.
Roles, tenancy, and the isAdmin story
Multi-tenant means every user belongs to one or more organizations (tenants), and every action has to respect tenant boundaries. The first version of Ensemble had a simple isAdmin boolean on users — true if you administered anything, false otherwise. This was wrong, and replacing it is one of the recent architectural projects worth describing.
The problem with a flat isAdmin: it can’t distinguish between “I run a single small theater” and “I administer the whole UCA coalition.” A theater admin shouldn’t see other orgs’ member data; a coalition admin needs to. Worse, the flat boolean made auth checks scattered — every endpoint had to know what kind of admin it was checking for, and easy to get wrong.
The replacement is a layered model:
isSystemAdmin— coalition-level superuser. Sees everything across all tenants. UCA staff, the platform operator.isTenantAdmin— admin of a specific tenant. Sees that tenant’s members, content, finances; can manage staff within that tenant.org role— per-organization role within a tenant.staff,volunteer,member,donor, etc. Carries permissions specific to the role.
Each check is now an explicit assertion against the right level — requireSystemAdmin(), requireTenantAdmin(orgId), requireOrgRole(orgId, 'staff'). The checks are also where auth bugs go to die: every endpoint declares its requirements at the top, and the platform-wide audit log records every elevated action.
The refactor caught real bugs. Going through every endpoint and re-asserting its requirements under the new model surfaced several places where the old flat isAdmin had been silently doing the wrong thing. The most serious: a member-export endpoint that gated on isAdmin was returning all tenants’ members to anyone with isAdmin = true, including admins of unrelated tenants — a small-theater admin could have pulled the museum’s donor list. No one had, and the audit log confirmed it hadn’t happened in production, but the bug had been latent for months and the flat boolean had been hiding it. Two similar cases turned up in admin-dashboard data queries that joined across tenant boundaries without filtering. Replacing one boolean check with the layered model didn’t just clean up the API surface; it forced every privileged code path to be re-justified, and three of them couldn’t justify themselves.
Cross-subdomain sessions
The companion apps (Ensemble Market and, eventually, others) live at different subdomains: market.utahculturalalliance.org, the identity federator at api.utahculturalalliance.org, member-facing portals at <tenant>.utahculturalalliance.org, and so on. A user signed in to one should be signed in to all of them — that’s the whole point of federation. But cross-subdomain session sharing is a place where security and convenience are in real tension.
The approach: session cookies are scoped to .utahculturalalliance.org (with the leading dot, making them available across all subdomains), with SameSite=Lax, Secure, and HttpOnly flags set. Admin hostnames are explicitly gated in the SvelteKit hooks — a request to admin.utahculturalalliance.org requires an admin-elevated session, not just a member session, even if the cookie is technically present. The hostname check happens in hooks.server.ts before any route logic runs.
CSRF is handled by a double-submit cookie pattern: a CSRF token is set in a non-HttpOnly cookie alongside the session, and every state-changing request must echo it back as an X-CSRF-Token header. The server compares the two values and rejects on mismatch. The pattern works because cross-origin requests can read neither the cookie value (different origin) nor inject the matching header (cross-origin headers are restricted), so an attacker who can trigger a request from a malicious page can’t make that request authenticate. It’s not the only CSRF defense in play — SameSite=Lax blocks most of it before the double-submit check runs — but it’s the explicit one we test against.
The federation endpoints
This is the part that makes the platform a cooperative rather than just a federated IdP. Companion apps — like Ensemble Market — don’t hold payment processor credentials, don’t hold member PII beyond what the user is currently editing, don’t talk to Neon directly. Instead they call back through Ensemble:
- A companion app needs to charge a saved card → it calls
POST /federation/payments/chargewith the user’s session token; Ensemble talks to Stripe with the platform’s credentials and returns a result - A companion app needs to look up a member → it calls
GET /federation/members/{id}with appropriate scope; Ensemble validates the scope, fetches from Neon if needed, returns a minimum-necessary projection - A companion app needs to send an email → it calls
POST /federation/email/dispatch; Ensemble uses the platform’s transactional email integration
This means: a security breach in any companion app exposes only what that app was already authorized to see. The blast radius of an Ensemble Market compromise is “Ensemble Market data”; it isn’t “all of UCA’s payment processing.” The architecture forces the right thing.
It also means: when a new compliance requirement lands (PCI scope reduction, GDPR-like data minimization, audit log requirements), it gets implemented once at the federator, not 7+ times across companion apps.
What it does not do
Things Ensemble is deliberately not:
- A CRM. Neon CRM remains the source of truth for member records, donation history, and event registrations. Ensemble syncs from Neon; it does not own the data.
- A general-purpose IdP. It’s not Auth0. It’s not designed to be the auth backend for arbitrary third-party SaaS. The federation endpoints serve the cooperative; the scope is intentional.
- A SaaS for the orgs to adopt individually. Member organizations don’t deploy Ensemble; UCA does, once, and all the orgs share that deployment. The architecture is multi-tenant because that’s the only economically viable shape for the cooperative.
- A drop-in replacement for anything. Existing org workflows (their websites, their mailing lists, their ticketing) keep working; Ensemble is additive. Migration is opt-in per capability, per org.
Where it’s going
The roadmap, as of writing:
Done and in production:
- Multi-tenant OAuth/OIDC with full session management
- 8,319-account shadow migration from Neon CRM
- MFA (TOTP + backup codes)
- Layered role model (
isSystemAdmin/isTenantAdmin/ org roles) - Cross-subdomain session sharing with hostname gating and double-submit CSRF
- Three-way product sync: Neon CRM v1 ↔ Stripe ↔ D1, bidirectional, Neon as primary
- Federation endpoints for payments, member lookup, email dispatch
- Legal document versioning with SOC 2-style acceptance logging
- Multi-host D1 replica architecture (Cloudflare primary + AWS Amplify + local SQLite) with write forwarding
- Admin dashboard with child tenant management and branding inheritance
Next:
- Membership-level sync via Neon v2’s memberships API — currently Ensemble knows which member belongs to which org, but not what kind of membership (annual, sustaining, lifetime). Adding this enables per-membership entitlements in companion apps.
- Individual membership subscription sync — for orgs that have moved their recurring-membership billing to Stripe, the subscription state needs to round-trip through Ensemble. The webhook plumbing exists; the reconciliation logic is the work.
- Companion apps beyond Market — ticketing, donor portal, volunteer scheduling. Each is its own project; each builds on the federation primitives Ensemble already exposes.
Longer term. A platform like this only really works when the organizations using it have some say in how it evolves. Most of that conversation is governance, not engineering, and most of it is happening between UCA and its member orgs rather than in this case study. The technical architecture is the shape it is — multi-tenant from day one, member orgs as first-class participants rather than tenants of a vendor, federation endpoints rather than locked-down API access — partly to leave room for that conversation. Whatever shape the longer-term operating model takes, the platform shouldn’t have to be re-architected to support it.
What I learned
A few honest takeaways from building this:
Identity is harder than it looks until you’ve done it; then it’s exactly as hard as it looked. OAuth/OIDC is well-specified, and any individual flow is straightforward to implement. The complexity is in the combinations: multi-tenant + federation + shadow migration + saved payment methods + MFA + cross-subdomain + legal doc versioning. Each layer is reasonable; the product is the layers compounding.
The legacy system is the requirement. It would have been faster to write a clean greenfield IdP and tell UCA to migrate everyone manually. It would also have failed. The shadow account system, the Neon-as-source-of-truth choice, the careful preservation of existing member relationships — these are the parts that made Ensemble useful in practice, and they’re the parts that don’t appear in any clean architecture diagram.
The economic model shapes the technical choices. Cloudflare Workers + D1 instead of AWS + Postgres. Self-host instead of Auth0. One platform serving 650 orgs instead of 650 deployments. None of these are the choices a well-funded startup would make; all of them are correct for the cooperative.
If you’re building shared technology infrastructure for an underserved sector — or if you’re thinking about whether to roll your own IdP for a multi-tenant cooperative — email me. The patterns generalize beyond cultural nonprofits, and I’d rather see them used than re-derived.