Build Strategy
Published
The question usually arrives after a security review, phrased as a suggestion: could we protect each tenant’s bundle separately, so a compromise of one does not affect the others? It sounds like defence in depth and it is worth taking seriously, because the answer is not simply no. It is almost always no, for these specific reasons, except in these three cases where it is clearly yes — and the cases are easy to tell apart once you look at what a per-tenant build actually changes.
What a per-tenant build changes, and what it does not
Protected output is polymorphic by default: protect the same source twice and you get two different artifacts. That property is genuinely useful, and it makes per-tenant builds trivially easy to produce, which is part of why the idea is tempting. But look at what varies and what does not.
What varies is the surface. Different generated identifiers, a different string layout, a different arrangement of the same control flow. What does not vary is the application. Every tenant is running the same product, so every per-tenant bundle contains the same logic expressed differently. A reader who wants to understand your client reads the copy served to their own tenant, at their leisure, and learns everything the other copies would have taught them.
So the security delta is narrow. Per-tenant builds do not stop reading, because each tenant has a readable-by-them copy. They do not stop tampering, because tampering happens in the browser the tenant already controls. What they do provide is distinguishability: two copies of your client can be told apart, which matters for attribution and for nothing else. Whether that is worth the cost depends entirely on whether you have an attribution problem.
The costs, which are not theoretical
Your shared cache evaporates. This is the big one and it is often discovered after the change. With one build, the first request for a file warms an edge cache that every subsequent tenant benefits from. With N builds you have N distinct URLs whose bytes never collide, so edge hit rate falls, origin egress rises, and every tenant pays a cold start of their own. If you have spent effort on long-term caching — and if you have not, it is worth reading about first — per-tenant builds undo a good deal of it.
Your release matrix multiplies. One release becomes N protection runs, N sets of artifacts to store, N sets of hashes to sign, and N protection reports to retain. Retention matters more than teams expect: symbolicating a stack trace requires the report from the exact build that produced it, so per-tenant builds mean per-tenant report archives for as long as you support that release.
Incident response gets a new first question. Today a bug report starts with what version. With per-tenant builds it starts with which build for which tenant, and the answer has to be right before a stack trace means anything.
Testing gets fuzzier. You test one build and ship a different one to most customers. In practice the transforms are deterministic in behaviour and this is fine, but you have moved from we shipped what we tested to we shipped something equivalent to what we tested, and that is a sentence you will have to defend at some point.
The three cases where per-tenant is right
1. The code leaves your infrastructure. If a customer runs the application on their own servers or inside their own network, they hold an artifact you cannot update or revoke on demand. That changes the calculus completely: a per-customer build lets you lock, watermark and expire that specific copy. This is a different distribution model rather than a variation on SaaS, and it has its own guide.
2. You need leak attribution. If your product is the kind that turns up re-hosted, or if a contract obliges you to identify the source of a disclosure, a per-build watermark gives you a defensible answer. The watermark is an HMAC-SHA256 tag with a signature, so a recovered file can be tied back to a release rather than merely resembling one. Attribution is the one benefit per-tenant builds deliver that nothing else does — but note that it needs a per-tenant watermark specifically, not just a per-release one.
3. Tenants genuinely receive different code. Bespoke modules, contractual customisations, an enterprise tier with an entirely different feature set compiled in. Here you are not choosing per-tenant builds; you already have them, and the only question is whether protection runs per variant. It does, and that is unremarkable.
Everything else — per-tenant theming, per-tenant configuration, per-tenant feature flags — is data, not code. Serve one build and let it read its configuration at runtime.
Names, seeds and the project boundary
If you do end up with multiple builds, one engine behaviour matters more than any other and it is easy to get wrong.
Replace Globals renames global functions and variables using one shared mapping across every file in the same project. That guarantee is what makes cross-file renaming safe, and it stops precisely at the project boundary. Two independently protected builds do not agree on generated names, and they were never meant to. If a shell and a plugin, or two separately protected bundles, must resolve the same global at runtime, generated names will not do it. Pin the name with Custom Identities, which accept a fixed name instead of a generated one, and the two builds agree because you told them to.
Separately, if you need two protection runs to produce byte-identical output — for build attestation, artifact diffing, or a stable Subresource Integrity hash — that is what the Seed option exists for. The same input, the same options and the same seed produce the same bytes on any machine. Note the interaction with the previous section: seeding makes a build reproducible, it does not make two different builds agree on names.
And note the tension. Per-tenant polymorphism and a fixed seed pull in opposite directions. If your reason for per-tenant builds is attribution, you want them to differ. If your reason is reproducibility for an attestation process, you want them not to. Decide which property you are buying before you configure it.
Locks in a multi-tenant world
Domain locking is the one per-tenant control that is cheap enough to be worth it on a shared build. The domain lock reads the hostname of the document it is running in and supports matching subdomains, so app.example.com plus subdomain matching covers acme.app.example.com and every other tenant under the same parent without a separate build per tenant.
Two behaviours to plan for. Customers on their own vanity domains need their hostnames in the allowlist, which is the one genuinely per-tenant configuration item. And an empty hostname is treated as a failure and fires the configured action before the allowlist is consulted at all — which is correct for a hostile context but also catches file-based previews and some embedded contexts. If your support team ever opens a bundle from disk to reproduce a customer issue, they will meet this.
It is worth restating what the lock is for. It raises the cost of re-hosting your client somewhere else. It is not an authorization control, and it never sees whether the person in front of it is entitled to the tenant they are asking for.
Where isolation actually lives
The security review question deserves a direct answer rather than a build-pipeline one. Tenant isolation means tenant A cannot read tenant B's data. That property is established in exactly one place: authorization on the server, evaluated per request against the authenticated principal, with the tenant scope derived from the session rather than accepted from the client.
Every frontend control sits downstream of that. The bundle running in tenant A's browser is under tenant A's control — they can read it, patch it, and replay its requests with edited parameters using nothing more exotic than the network tab. If a tenant identifier in a request body is enough to change which tenant's data comes back, no build configuration fixes it, and the fix is not on the client at all.
What the protected frontend contributes is worth having and worth describing accurately: it raises the cost of understanding your client-side logic, it makes in-place tampering with that logic harder to do quietly, and with runtime defense wired to your monitoring it turns tampering attempts into signals you can see. That is a genuine set of controls. It is simply a different control family from isolation, and a reviewer will respect the distinction more than a claim that blurs it.
The short version
Ship one protected build to every tenant unless you have one of three specific reasons not to: the code leaves your infrastructure, you need leak attribution by customer, or tenants genuinely run different code. Per-tenant builds cost you shared caching, a multiplied release matrix, per-build report retention and a harder first question during incidents, and they buy distinguishability rather than protection, because every tenant can already read their own copy. Lock the shared build to your application domain with subdomain matching, pin any cross-build name with Custom Identities rather than hoping generated names agree, use a seed when you need reproducibility, and keep tenant isolation exactly where it belongs — in server-side authorization.
Frequently asked questions
Should a multi-tenant SaaS build a separate obfuscated bundle per tenant?
For the great majority, no. Every tenant runs the same application, so a per-tenant build produces different-looking output that contains the same logic, and the tenant who wants to read it reads their own copy either way. What you spend is real: shared CDN caching disappears, your release matrix multiplies, and every incident starts with working out which build the reporter was running. Ship one protected build and put isolation where it belongs, on the server.
When is a per-tenant protected build genuinely worth it?
Three cases. When the code physically leaves your infrastructure, as with on-premise or customer-hosted deployments, because each customer then holds an artifact you cannot revoke. When you need leak attribution and a per-build watermark has to identify which customer's copy appeared somewhere it should not. And when a tenant genuinely gets different code, such as a contractual bespoke module, in which case you were already building separately.
Does obfuscation provide tenant isolation?
No, and it is worth being blunt because this claim occasionally reaches a security questionnaire. Tenant isolation means tenant A cannot read tenant B's data, which is decided entirely by authorization on the server. A protected frontend running in tenant A's browser is under tenant A's control, and the requests it makes can be replayed with edited parameters no matter what the bundle looks like. Obfuscation protects your logic from readers; it never separates one customer's data from another's.
How does a per-tenant build affect browser and CDN caching?
It removes the shared cache entirely. One build means the first tenant to request a file warms an edge cache every other tenant hits; per-tenant builds mean N distinct URLs with no reuse between them, so your edge hit rate falls, your origin egress rises, and cold-start latency lands on every tenant separately. Protected output is polymorphic by default, so two builds of identical source differ, which means you cannot rely on file names or hashes colliding into a shared cache entry.
Can I keep identifier names consistent across separate per-tenant builds?
Not by accident, because a shared rename mapping only applies within one project. Replace Globals uses one mapping across every file protected together, and that guarantee stops at the project boundary. If two builds must agree on a name, pin it with Custom Identities, which accept a fixed name rather than a generated one. If you need two runs to be byte-identical rather than merely compatible, supply the Seed option: the same input, options and seed produce the same output.
Does a domain lock work when every tenant has its own subdomain?
Yes, and this is one of the few places a per-tenant decision is cheap. The domain lock reads the hostname of the page it runs in and supports matching subdomains, so a single shared build can be locked to your application domain and still serve every tenant subdomain under it. Reserve per-tenant hostname lists for customers on their own vanity domains, and remember that any lock behaves as a failure when the hostname is empty, which is what happens in some preview and offline contexts.
Related reading