Written for: developers who built something real with Lovable, Bolt or v0 and are now being asked to make it multi-tenant.
This is Part 2 of The L...
For further actions, you may consider blocking this person and/or reporting abuse
Disclosure first: I'm building Mythex, another tool in this category, so read accordingly. Two additions to test 1, because an API route can return a clean 403 while the data leaks around it. Files: uploads often sit in a bucket at a predictable path behind a public or long-lived URL, so request tenant B's file URL directly, not through the API. Background work: exports, scheduled emails and webhook handlers usually run with a service key and no signed-in user, so the tenant has to come from the job's own data. Trigger an export as tenant A and read what lands in the file. Does your shared data layer cover jobs that run without a principal?
Both points land, and the second one lands harder than I would like.
The direct answer is no. Our background jobs use a system service identity that sits above the tenant boundary, so it has the same basic shape as the service role key I criticize in the post. When a job needs to act for a specific tenant, the tenant ID is passed in the job data rather than derived from the authenticated principal. Our connector worker currently reads it from the source row, which is exactly the pattern you described.
That also means one sentence in the post is too broad. The claim that tenant context is "resolved once in the shared data layer every service calls" is true for the CRUD read path in one service. There, the tenant comes from the principal, and CI fails if a new read path bypasses that layer. But it isn’t true for every service, and background jobs sit outside that protection. I am correcting the post.
On files, the normal path doesn't expose bucket URLs. Files are streamed through an authenticated endpoint that checks the tenant before returning anything, and storage keys are prefixed by domain, so another tenant's file ID shouldn't resolve.
But your question made me check the other deployment modes instead of assuming they behaved the same way. One of them does allow files to be fetched without going through that check. We are fixing it, and I'll update the post because test 1 currently sounds more complete than it really is.
So that's two real additions to a list I thought was finished. Thanks for taking the time to dig into it.
Respect for checking the other deployment modes instead of defending the sentence. One pattern that closes the job gap without giving up the service identity: capture the tenant at enqueue time from the authenticated principal, put it in the job as a signed claim, and have the worker open a tenant-scoped connection from that claim before touching data (with Postgres RLS, SET LOCAL app.tenant_id inside the transaction). The service identity then runs jobs but can't cross tenants by accident, and a job with no claim fails closed. Jobs that really are cross-tenant (billing rollups, cleanup) get a separate, explicitly named role, so they're easy to list and review.
That's the right model. The part we are missing is signing the tenant context.
Our connector jobs already carry the tenant ID as job data, but it isn't signed. Worse, if the tenant is missing, the job doesn't fail closed—it falls back to whatever scope the credential defaults to. Capturing the tenant from the principal when the job is created, then verifying a signed claim in the worker, should be a relatively small change to something we already have.
The RLS part doesn't map directly to our architecture. We don't enforce tenancy at the database layer — it's application logic — so for us "open a tenant-scoped connection" becomes "resolve the tenant once at the start of the job and refuse to continue if the claim is missing or invalid." It gives us the same intended behavior, but through a weaker enforcement mechanism, and I don't want to pretend otherwise.
The biggest takeaway for me is separating genuinely cross-tenant jobs from jobs that merely have the technical ability to cross tenants. Right now both use the same identity, so they are impossible to distinguish during a review. Giving legitimate cross-tenant work its own named role would at least make that access visible and auditable. If nobody can produce a complete list of what can cross tenants, it isn't much of a boundary.
The four tests are the useful part. When you invent the two orgs for the cross-tenant curl, use domains you control and reserved fiction phones (US 555-0100 to 555-0199, UK 020 7946 0xxx) so a list or export route that still returns 200 never sprays a real person. Same for any signup email column on those fixtures: MX / Null MX before you treat the row as a contact you can mail later (timeout = unchecked).
Good catch. That's exactly the trap I would have walked into.
The point of test 1 is to find a route that returns 200 when it shouldn’t. But if that route triggers an export or notification, a successful test could mean real data was actually sent somewhere. Making up a domain and accidentally choosing a real one would be a painful way to discover that.
I am adding the reserved domains and address ranges to the post. The null MX point was new to me as well. Treating a DNS timeout as "not verified" instead of assuming the address is safe to receive mail is definitely the better default. I probably would have made the opposite choice.
Good call. Keeping those fixtures reserved and treating timeouts as unchecked keeps the test from creating a second failure.
Agreed, the third state is the whole point. It's gone into the post as reserved-for-fiction fixtures plus treating a lookup timeout as unchecked rather than mailable. Thanks for pushing on it.
That is the failure mode I was trying to isolate. Keeping the fixture domains controlled makes it easier to tell whether a 200 means a real side effect or only a mocked path.
That is a sensible addition. Keeping the timeout as an explicit third state is what prevents the fixture from turning an infrastructure gap into a confident mail decision.
"Generation is local. Invariants are global." That line explains most of the rescue work we see after a Lovable or Bolt prototype gets its second customer. The screens are fine. What is missing is the tenant boundary on every query, an idempotent refund path, and an audit trail someone can read after an incident.
One practical point for founders reading this: the missing 30% is easier to buy and to verify when it is scoped as its own piece of work. List the invariants, write a test for each (tenant A can never read tenant B, a retried webhook never double charges), and treat those tests as the definition of done.
That turns a vague "make it production ready" into something you can check line by line.
This is the useful way to frame it. "Make it production-ready" is too vague to estimate, but "here are six things the system must guarantee, along with a test for each one" is something you can actually scope.
The catch I have run into is that some guarantees are easy to verify from the outside, while others aren't. You can test that tenant A can't access tenant B's data, or that retrying a webhook doesn't charge someone twice. Those are straightforward to prove against a running system.
But something like "six months from now, can we tell who viewed this record?" is different. You won't learn that from the API response. You have to inspect the system and confirm that read events are actually being recorded. Compensation logic is similar, you usually find out it's missing only when you deliberately make part of the workflow fail.
So I would only treat the list as a real definition of done if every item also says how it will be verified. The requirements without an obvious external test are often where most of the hidden rescue work is.