Running one PBX for one company is a solved problem. Running one platform that serves many companies at once is a different job, and “we give every customer their own SIP domain” is the start of it rather than the whole of it. A tenant boundary has to hold in five separate places: the switch, the dialplan, calls arriving from carriers, the database, and every webhook and media socket around the edges. Break it anywhere and a customer can see another customer’s calls. Everything below comes from building exactly that on FreeSWITCH, and every failure described was found in our own testing before launch.
1. A Domain Per Workspace, and Registrations Filed Under It
Each workspace gets its own SIP domain, so extension 1001 in one company and extension 1001 in another are different addresses rather than a collision waiting to happen. That much is standard advice.
What the advice leaves out is that the switch has to agree. FreeSWITCH can be configured to file every registration under its own address instead of the domain the phone signed in with. With that setting on, extension 1001 registers successfully, the phone shows itself as connected, and no lookup by workspace ever finds it. Calls to that extension fail while everything looks healthy. Check where registrations are actually filed before you trust a per-domain design, and remember the setting is read when the SIP profile starts, so changing it means restarting a profile and dropping the calls on it.
2. Authentication Answers Who You Are, Not Whose Data You May See
This is the failure worth learning from. A desk phone signed into workspace A could subscribe to extension 1001 in workspace B and receive B’s live call activity, including the numbers B’s staff were talking to. Nothing was misconfigured in the usual sense. The switch checked the password correctly, against the domain in the request’s own From header, which was A’s. It never compared that with the domain being subscribed to.
Authentication had answered the wrong question. It confirmed the phone was a genuine phone from workspace A, and then let it watch workspace B, because nothing in the path was responsible for asking whether A may look at B.
The fix belongs in the directory lookup, because that is the one place holding both facts at once. When the switch asks your service for a password it sends the target of the request in the same message. Compare the two: for SUBSCRIBE, PUBLISH and MESSAGE, the target domain must be the login’s own domain.
One detail keeps that from breaking ordinary use. Refusing every request whose target is not the login’s domain would also refuse phones configured with the server’s IP address in the request line, which plenty of handsets do. So for other methods the check refuses only a target that names a different workspace, and leaves an IP-addressed request alone.
3. The Dialplan Is a Boundary, Not Just Routing
Each workspace routes in its own dialplan context, named after the workspace. The service that builds dialplans answers only for contexts it owns and returns nothing for the switch’s stock contexts, so a call that somehow lands outside a workspace gets no routing at all rather than generic routing.
Feature codes are where this gets tested. Call pickup and intercom let one phone answer or interrupt another, which is exactly the behaviour you do not want crossing a tenant line. Both are allowed only when the switch says the call came from a phone that authenticated against the directory, and when the workspace id stamped on that call matches the context asking for the route. Two facts, both read off the live call, not inferred from the number dialled.
4. Carrier Calls Arrive With No Login At All
Everything above assumes a SIP login. Calls from a carrier have none: they arrive on a separate profile that accepts calls without authentication, because that is how carriers deliver them. The number dialled is the only clue to which workspace the call belongs to, and a number alone is something anyone can send.
So the handover has two stages. The dialled number is looked up across all workspaces in the forms carriers actually send it — with a country code, with a leading zero, as ten digits — and then the call is handed to that workspace only if the packet’s source address is one the number’s trunk is allowed to send from. That means the addresses listed on the trunk, or, if none are listed, whatever the trunk’s SIP host resolves to, cached briefly. A host that does not resolve trusts nothing.
| Situation | What happens |
|---|---|
| Number known, source matches the trunk | Handed to the workspace, where its inbound routes, business hours and recording apply |
| Number known, source does not match | 403 Forbidden, logged |
| Number nobody owns | Treated as not found, so the switch’s stock behaviour is unchanged |
| Workspace has no trunk configured | No carrier call is accepted for it |
Without that source check, anyone who can reach the port can claim to be any customer’s carrier by dialling their number. It is the cheapest control in this article and the one most often left out.
5. The Database Should Refuse What the Code Forgets
Application code filters by tenant in every query until the day somebody writes the one query that does not. Postgres row-level security turns that from an outage into a non-event: every tenant table carries a policy allowing only rows whose tenant matches the current session, and the platform operator role is the single documented exception. Our schema is 85 policy statements, 70 of them the same rule repeated on every tenant-owned table.
- Use FORCE ROW LEVEL SECURITY, not just ENABLE. Without it the table owner bypasses its own policies, and the owner is often exactly the role your service connects as.
- Set the tenant for the session inside the transaction that does the work, so a pooled connection cannot carry one customer’s identity into another customer’s query.
- Give the application role no rights it does not need. The policy is the second line; the grant is the first.
- Write the audit trail to the same rule. A customer should be able to see that the platform suspended them, without reading the internal note about why, and without being told which staff member did it.
Row-level security is worth more than the code review it replaces. It converts a category of mistake that leaks data quietly into one that returns nothing and gets noticed.
6. Webhooks and Media Sockets: the Boundary Everyone Forgets
The SIP side can be watertight while the web side is wide open, because the web side is where the platform talks to providers rather than to customers.
Telephony providers post call status to callback URLs that usually carry a record id. If those ids run in sequence and the endpoint does not verify who is calling it, an outsider can change another customer’s call records by counting upwards. Every provider offers a signature or a key for this; use it, and reject the request when it is missing rather than when it is wrong.
Two things worth knowing while you test that. A 200 response does not mean the request was accepted — at least one major provider answers unsigned requests with 200 and an invalid-signature reason in the body, so assert on the body and on whether the record actually changed. And any live-audio WebSocket keyed only by ids in the URL is readable by anyone who can guess them: mint a single-use token when the call is created and redeem it atomically when the socket opens.
How We Test It, and What Does Not Count
Reading the code is not evidence. Ours is checked by creating two fresh tenants and having each attack the other through every operation the API exposes — 161 of them, 80 carrying an id in the path — plus a separate harness that forges every telephony callback and compares the target record before and after.
- 70 cross-tenant reads and writes refused.
- No list endpoint returned another tenant’s rows, and no API-key path did either.
- The forged-callback harness is what found the unauthenticated status callback described above; it was fixed and the fix proven against a real record.
The harness matters more than the result. Isolation is not a feature you finish; it is a property that every new endpoint can break. A test that tries to break it, and runs on every change, is the only version of this work that stays true.
Tenant isolation is five boundaries, not one setting: the switch must file registrations where you think it does, authentication must be told whose data the caller may see, the dialplan must own its contexts, carrier calls must be checked by source address, and the database must refuse what a missed WHERE clause would allow. Webhooks and media sockets need the same discipline, and an attack harness is what keeps all of it honest. We build and audit multi-tenant telephony platforms on FreeSWITCH, Asterisk, Kamailio and OpenSIPS — see our VoIP engineering work, hire a VoIP developer, or read how the browser softphone on this platform was built.