Security Basics
8 min
A customer changes a number in an API request and lands on another company’s invoice. Or a new Supabase query works perfectly in testing because it bypasses row-level security. These are not theoretical edge cases. Secure multi tenant data isolation is the control that stops a small authorisation mistake becoming a reportable data exposure, a lost customer, or both.
For a SaaS product, every request needs an answer to a simple question: which tenant is allowed to read, change, approve, download, or delete this record? The answer cannot depend on a hidden field in the browser, a customer ID supplied by the client, or an assumption that users will only click buttons intended for them.
AI-assisted development makes this easier to miss. Generated code can produce convincing CRUD endpoints quickly, while leaving ownership checks incomplete, trusting a request parameter, or using a privileged database key in the wrong place. The application appears to work. The security boundary has simply not been tested.
Secure multi tenant data isolation is an access-control problem
Multi-tenancy means one application serves multiple customers while keeping their data and actions separate. That separation can be implemented in several ways: a shared database with a tenant identifier on each row, separate schemas, separate databases, or isolated deployments for higher-risk customers.
There is no single correct architecture. Separate databases offer a stronger natural boundary and can help with enterprise requirements, but add operational overhead, migration complexity, and cost. A shared database is efficient and common for early-stage SaaS, provided the application and database enforce tenant scoping consistently.
The key word is enforce. Adding tenant_id to a table does not isolate anything on its own. Every database query, API route, background job, file download, webhook handler, admin action, and analytics export must apply the correct boundary. One unscoped route can defeat an otherwise sensible design.
An IDOR, short for insecure direct object reference, is the familiar failure mode. If /api/invoices/1234 returns an invoice because the requester is signed in, but never confirms that invoice 1234 belongs to their tenant, any authenticated user may be able to retrieve it. Changing an identifier is often all that is required.
Where tenant boundaries usually fail
The most serious problems are often small implementation shortcuts rather than exotic attacks. A founder may have correctly protected the dashboard, but left an API endpoint callable directly. An engineer may have added RLS policies to the main table but overlooked a related table containing notes, attachments, or payment metadata.
The client chooses the tenant
A request body such as { "tenantId": "abc" } should be treated as untrusted input. So should a tenant ID in a query string, a hidden form field, local storage, or a frontend state object. These values can be changed in seconds.
The server should derive tenant context from a verified session, then check that the requested resource belongs to that tenant. In some products, a user can legitimately belong to more than one workspace. In that case, the active workspace must still be validated against the user’s membership on every sensitive request.
Authentication is mistaken for authorisation
Authentication answers, “Who is this?” Authorisation answers, “Can they perform this action on this record?” A valid JWT proves neither tenant membership nor permission to approve a refund, view payroll data, or invite another administrator unless the application checks the relevant claims and relationships.
Be cautious with JWT claims that are trusted long after a user’s role or membership changes. Tokens should be correctly signed, verified with the expected algorithm and issuer, and have sensible expiry. More importantly, high-impact decisions should be backed by current server-side data where stale claims would create risk.
RLS exists, but privileged access bypasses it
Supabase row-level security is a useful database-level backstop for shared-table applications. A typical policy checks that a row’s organisation or workspace ID matches a membership associated with the authenticated user. It helps ensure that a missed filter in application code does not automatically expose every row.
But RLS only protects what it covers. Policies need to exist for SELECT, INSERT, UPDATE, and DELETE as appropriate, not merely reads. Related tables, views, storage objects, RPC functions, and newly added tables need review as well. A policy that permits reads but allows arbitrary updates can still lead to fraud or account takeover.
The service-role key deserves particular scrutiny. It bypasses RLS by design and belongs only in a protected server environment. If it reaches a JavaScript bundle, a public repository, a browser-accessible configuration value, or an error log, the tenant boundary may be irrelevant. Rotate the key, remove it from the exposed location, and investigate what it could have accessed.
Background work and integrations are trusted too much
Webhooks, queue consumers, scheduled tasks, and export services often run with broad privileges. They may receive a tenant identifier from an event payload and execute a query without independently validating the event source or the resource relationship.
Payment webhooks require signature verification before any database action. For other integrations, verify the sender, use scoped credentials where possible, and avoid turning a payload into authority. A background job should load the relevant record using a trusted internal reference, then enforce the same tenant rules as the public application.
Files, search, and admin routes fall outside the model
Documents are data too. A private storage bucket is not enough if a signed URL can be issued for any object path supplied by a user. Object names should be tied to a verified tenant, and downloads should check access before issuing a URL.
Search indexes and data exports deserve the same treatment. A global search endpoint can leak snippets across tenants when filters are missing. Internal admin tools need explicit roles, audit logs, and stronger safeguards than a route merely hidden from the navigation. A support user who can switch tenants should do so visibly and deliberately, not through an unrestricted query parameter.
Build the boundary in more than one layer
For most startup products, the practical approach is defence in depth. The API verifies identity and membership. Database queries use tenant-aware access patterns. RLS blocks accidental cross-tenant queries. Sensitive actions add role checks, and logs create a trail when something unusual happens.
Do not rely on frontend restrictions as a security control. Disable buttons for usability, but assume an attacker can call every endpoint directly. Equally, do not treat database policies as a reason to make server code careless. Layers reduce the chance that one rushed change becomes a breach.
A useful design pattern is to make tenant scoping difficult to forget. Instead of handing route handlers unrestricted database access, use a repository or helper that receives verified tenant context and applies it by default. For example, an invoiceRepository.forTenant(tenantId) interface makes the safe route easier to write than a free-form query.
This does not remove the need for review. It does make the intended security model visible in the codebase and gives new contributors, including AI coding tools, a safer pattern to follow.
Test isolation like an attacker would
Unit tests that confirm a user can view their own record are necessary but incomplete. The highest-value test is often the inverse: create two tenants, sign in as a user from tenant A, and attempt to access, edit, delete, download, or export tenant B’s data using direct API calls.
Test predictable identifiers and opaque identifiers alike. UUIDs reduce casual guessing but do not solve authorisation. Check nested objects too: a tenant-safe project endpoint may expose another tenant’s task, comment, or attachment through a related query.
Before a release, test at least these paths:
Read, update, and delete endpoints using another tenant’s object ID.
File uploads and downloads, including manually altered storage paths.
Search, reporting, CSV exports, and bulk actions.
Role changes, workspace switching, invitations, and password recovery flows.
Webhooks, scheduled jobs, and internal tools that use elevated credentials.
Automated scanners can find exposed keys, missing headers, vulnerable dependencies, and obvious endpoint issues. They will not reliably understand whether invoiceId belongs to the workspace in a particular request. That requires manual review of the application’s data model, routes, policies, and real request behaviour.
A focused security audit should therefore trace a record from browser to API to database and storage, then attempt cross-tenant access at each point. HollowByte combines that manual testing with code and configuration review, prioritising the issues that could expose customer data now and identifying the exact routes, policies, keys, or files that need attention.
Fix the exposure before adding more features
When a cross-tenant issue is found, start by limiting access. Disable or restrict the affected endpoint if the fix needs time, rotate exposed credentials, and preserve relevant logs. Then correct the underlying authorisation check or RLS policy rather than adding a cosmetic frontend guard.
After the code change, retest with two real tenant accounts and confirm the database itself rejects unauthorised access where RLS is part of the design. Review neighbouring endpoints built from the same pattern. A vulnerable GET /projects/:id route rarely exists alone.
The next feature can wait a day. Customers will forgive a short maintenance window far more readily than discovering that their invoices, documents, or user records were visible to somebody else.
Back to blog
