Multi-Tenant Security for PEO Platforms: What Row-Level Isolation Actually Means
A plain explanation of shared-schema multi-tenancy with row-level security, written for the IT reviewer evaluating a PEO or broker platform.
When a PEO or brokerage evaluates a platform like ours, the sales conversation eventually gets handed to someone whose job is to say no. That person is an IT director, a security consultant, or a CFO who has been burned before, and their first question is some version of: if my data and a competitor's data live in the same database, what stops them from seeing mine?
It is the right question. This post answers it in plain terms, without a certification badge standing in for an explanation: how a shared-schema multi-tenant system with row-level security works, how we apply it, and what a reviewer should ask any vendor who claims to do the same.
Why shared-schema at all
There are three common ways to separate tenants in a SaaS product. You can give every customer their own database, you can give every customer their own schema inside one database, or you can put every customer's rows in the same tables and tag each row with the tenant it belongs to.
The first two sound safer, and for some workloads they are. But they carry costs that show up as risk in other forms. Hundreds of databases means hundreds of places to apply a migration and a long tail of tenants running a slightly different schema because a migration failed on one of them. Drift is a security problem, because the tenant running the old schema is also running the old permission model.
Shared-schema multi-tenancy puts every tenant's data in the same tables with a tenant identifier on every row, and then enforces isolation at the database layer so that a query can only ever return rows belonging to the tenant making the request. There is one schema, one migration path, one permission model, and every tenant is on it at the same moment. The trade is that the isolation mechanism has to be correct, because there is no second wall behind it.
That mechanism is row-level security.
What row-level security does
Row-level security, or RLS, is a feature of the database itself, not of the application. In PostgreSQL, which is what HALO IQ runs on, a table can have policies attached that act as a filter on every read and write. If the policy says a row is visible only when its `tenant_id` matches a tenant the current user belongs to, the database adds that condition to every query against the table, whether or not the application code remembered to.
That last clause is the whole point. In a system that relies on the application to filter by tenant, every developer who writes a query has to remember to add the filter, every code review has to catch the one who forgot, and one missed `WHERE` clause is a cross-tenant data exposure. With RLS, the filter is enforced below the application. A query without a tenant condition returns the current tenant's rows, not everyone's.
In HALO IQ, every tenant-scoped table carries a `tenant_id` column with RLS policies attached. A user's visible tenants are derived from a memberships table that links user identities to the tenants they have been granted access to and the role they hold there. The policy checks the row's tenant against that membership set. A user with memberships in two tenants (a broker who works with two PEOs, for instance) sees rows from both; a user with one sees one.
We also enforce the same filter in application code as a matter of discipline. But the belt is the database.
Never trust the header
Here is a subtle place where multi-tenant systems go wrong, and it is worth a reviewer asking about directly.
A request arrives at the API carrying a signed token that identifies the user. Somewhere, the system has to decide which tenant that request is operating in. The lazy design reads a tenant identifier from a request header and uses it. The problem is that headers are set by the client, and a client can send any value it likes. If the server trusts the header, a user who knows another tenant's identifier can ask for that tenant's data and the application will cheerfully pass that identifier down to the query.
The correct design treats the header as a preference, not a fact. The server validates the token, looks up the user's memberships, and resolves the tenant from the membership record. If the client asked for a specific tenant, the server checks that the user actually holds a membership there before honoring the request. If the user has no membership in the requested tenant, the request is rejected, regardless of what the header said. If no tenant was requested, the server defaults to the user's primary membership.
This is how HALO IQ's tenant resolution works. Tenant context comes from membership, every time. The header can narrow which of your tenants you are working in; it can never widen it.
Keys that must never reach the browser
Database platforms like the one we use expose two kinds of credentials. One is a limited key intended for client-side use, which identifies the application but carries no privileges beyond what RLS and the user's own session allow. The other is a service-role key that bypasses RLS entirely, meant for trusted server-side operations like background jobs, cross-tenant administration, and migrations.
The service-role key is the skeleton key. If it ever ships to a browser, in a JavaScript bundle, a frontend environment variable, or an API response, every tenant's data is exposed to anyone who reads it. This failure is almost always an accident: a developer uses the wrong key in the wrong file, and the build tooling bundles it.
Our rule is that the service-role key exists only on the server, is read only by a single server-side client factory, and is never referenced from frontend code. Our build process includes a scan that fails the build if a service-role credential pattern appears anywhere in the frontend source. The browser gets the limited key and the user's session, and nothing else.
A reviewer should ask any vendor: where does your privileged database key live, and what prevents it from being bundled into the client? If the answer is "we are careful," keep asking.
Roles and entitlements are two different gates
Isolation says which tenant's data you can see. It says nothing about what you can do with it. That is handled by two further layers.
The first is roles. A membership carries a role, and the available roles depend on the kind of tenant. A PEO tenant has roles like owner, admin, underwriter, account manager, and viewer. An MGA or broker tenant has producer and underwriter roles alongside the administrative ones. Each role maps to an access level per module: full, read-only, or none. An underwriter in a PEO tenant can work a mod projection in ModIQ and read the deal in ConnectIQ but cannot change the tenant's pricing rules; that permission belongs to an owner or admin.
The second is module entitlement. Not every tenant has every module. A tenant's type sets a default set of modules, and per-tenant overrides turn modules on, off, or into a limited preview. A user's effective access to any module is the intersection of what their role allows and what their tenant is entitled to. A role that grants full access to ClaimsIQ means nothing in a tenant that does not have ClaimsIQ enabled.
Both gates are checked on the server, per request. The frontend hides what you cannot use, but hiding is a courtesy, not a control.
Platform administrators are the exception, and they are logged
Someone has to be able to see across tenants: to support a customer, to run a migration, to investigate a problem. In our system that is a separate platform-administrator designation, held by a very small number of people, stored apart from tenant memberships, and required for any cross-tenant endpoint. Platform-admin actions are written to an audit log with the actor, the action, the target, and the time.
A reviewer should ask how many people hold that designation, how it is granted, and what gets logged when it is used.
What we are not claiming
We are not going to list certifications in this post, and a reviewer should be wary of any vendor who answers the isolation question by pointing at a badge. A certification tells you an auditor looked at a set of controls on a date. It does not tell you how tenant resolution works, where the privileged key lives, or whether RLS is enabled on every tenant-scoped table.
The short version for your reviewer
Every tenant's rows carry a tenant identifier, and the database enforces that a user can only read or write rows belonging to tenants where they hold a membership. The tenant for any request is resolved from that membership, never from a client-supplied value. The database credential that bypasses isolation lives only on the server and is scanned out of the frontend at build time. Roles per tenant type and module entitlements per tenant decide what a user can do once isolation has decided what they can see. Cross-tenant access is limited to a small set of platform administrators and is logged.
That is what row-level isolation means when it is done properly. It is not optional for a platform where brokers, PEOs, and MGAs who compete with each other work inside the same system. We built HALO IQ this way because the alternative was asking our users to trust us. We would rather they understand us.
HALO IQ
Underwrite and deliver the proposal in one file.
Submissions, mods, and proposals for PEO operators and brokers, without the spreadsheet handoff.
Was this article helpful?
