One Platform, Many Schools: Tenant Isolation for EdTech Done Right
Founder & Lead Engineer, RAITHub
RAITHub ships and tests production software. See QA as a Service or talk to us.
When one EdTech platform serves many schools, each school's students, grades and payments must never appear to another. Enforce that in the database, with a tenant column on every table and PostgreSQL row-level security, not in application code alone. Scope every query to the current school, and test the boundary on every build. A single cross-school leak of grades ends institutional trust, so isolation is a correctness requirement.
If you would rather have multi-school isolation built and tested for you, see how RAITHub would build it below.
This post is for EdTech teams building, or fixing, a platform that serves several schools, coaching centres or training providers from one system, the kind of product covered in the EdTech software development guide. It is engineering guidance drawn from RAITHub's multi-tenant work, including Sundor Skin, which runs 146 PostgreSQL tables with row-level security and 530+ automated tests. The general multi-tenant pattern is in PostgreSQL row-level security for multi-tenant SaaS; this post is the EdTech case, where the tenant is a school.
What does "tenant isolation" mean for a multi-school platform?
That every row belongs to exactly one school, and a request made on behalf of one school can never read or write another school's rows, no matter how the query is written. The school is the tenant. The hard part is that students, teachers, grades, attempts and payments all carry that boundary, and one forgotten WHERE clause is a leak.
| Data | Why the boundary matters | Leak consequence |
|---|---|---|
| Students and contacts | Personal data about children in many jurisdictions | A reportable data incident |
| Grades and attempts | Sensitive results tied to named students | Immediate loss of school trust |
| Payments and invoices | Financial records per school | Billing disputes and exposure |
| Teachers and roles | Who can see and do what, per school | Cross-school privilege escalation |
Should I isolate in application code or in the database?
Both, but the database is the backstop you cannot bypass. Application-level scoping (adding the school id to every query) is necessary but fragile: one missed filter, one new endpoint, one raw query, and data crosses the boundary. The database should refuse to return another school's rows even when the application forgets. The symptoms and triage of a leak that slipped through application code are in users can see another tenant's data.
| Approach | Strength | Weakness |
|---|---|---|
| Application code adds school id to each query | Flexible; close to the business logic | One forgotten filter leaks; grows riskier with every new query |
| Shared schema with row-level security | The database enforces it for every query, everywhere | Needs a policy on every table and correct session setup |
| Database or schema per school | Strong physical separation | Operationally heavier; migrations and pooling get complex |
For most multi-school EdTech products, a shared schema with row-level security is the right default. The trade-offs against database-per-tenant are in database per tenant vs shared schema.
How do I enforce school isolation with row-level security?
Put a school_id on every tenant-scoped table, turn on row-level security, and write a policy that limits every row to the school in the current session. PostgreSQL applies a row security policy as "a general expression that determines which rows are visible or modifiable according to the policy" (PostgreSQL: row security policies). A minimal setup:
-- Every tenant-scoped table carries the school.
ALTER TABLE students ADD COLUMN school_id uuid NOT NULL REFERENCES schools(id);
-- Turn RLS on, and FORCE it so even the table owner is subject to policy.
ALTER TABLE students ENABLE ROW LEVEL SECURITY;
ALTER TABLE students FORCE ROW LEVEL SECURITY;
-- The policy: a row is visible only when it belongs to the session's school.
CREATE POLICY students_tenant_isolation ON students
USING (school_id = current_setting('app.current_school_id')::uuid)
WITH CHECK (school_id = current_setting('app.current_school_id')::uuid);
The USING clause filters reads and the rows an update or delete can touch; WITH CHECK stops a write from putting a row into another school. The application sets the session's school once per request, from the authenticated session, never from user input:
// Run at the start of each request's transaction, from the verified session.
export async function withSchool<T>(
db: { query: (sql: string, params?: unknown[]) => Promise<unknown> },
schoolId: string,
work: () => Promise<T>,
): Promise<T> {
// set_config(..., true) scopes the value to this transaction only.
await db.query('SELECT set_config($1, $2, true)', ['app.current_school_id', schoolId])
return work()
}
Two rules keep this honest: derive schoolId from the authenticated session, never from a request parameter, and use FORCE ROW LEVEL SECURITY so a mistake in a privileged connection is still caught.
How do I handle students or teachers in more than one school?
Model membership as its own table, so a person can belong to several schools with a different role in each, while every data row still belongs to exactly one school. The identity is shared; the access is per school.
- One user record, many memberships. A
membershipstable links a user to a school with a role. Switching schools changes the session'sschool_id, and row-level security does the rest. - Grades and attempts belong to a school, not a user. A student's result in School A is School A's row; the same person's result in School B is School B's row. They never mingle.
- Roles are scoped to the school. A teacher in one school is a parent in another. Resolve the role from the active membership, not globally.
How do platform admins work across schools safely?
Through a deliberate, logged path, not by turning isolation off. Platform staff sometimes need to act across schools for support, but that must be an explicit, audited action, not an ambient super-power.
- Keep cross-school access out of the normal request path. A support tool that sets the
school_idto a chosen school, with a reason recorded, is far safer than a bypass flag. - Log every cross-school action. Who accessed which school's data, when and why, in an append-only audit log, as in designing an audit log customers trust.
- Never expose a "see all schools" query to tenant-facing code. The leak risk is not worth the convenience.
How do I prove isolation actually holds?
With tests that attack the boundary on every build, because isolation you have not tested is isolation you do not have. Two kinds of test matter.
| Test | What it checks |
|---|---|
| A CI check for tables missing a policy | No tenant table ships without row-level security enabled and a policy |
| A cross-school access suite (IDOR) | School A, authenticated, cannot read or write any of School B's records by id |
| Write-boundary tests | A create or update cannot place a row in another school |
| Membership-switch tests | Switching schools changes exactly what is visible, with no bleed |
Sundor Skin runs both a policy-coverage check and an IDOR suite against this exact boundary. Keep these tests in CI so a new table or endpoint cannot quietly skip isolation; regression testing on every deploy covers keeping them green. This is application-level data-isolation engineering, not a certified security assessment.
How long does multi-school isolation take to build?
Designing the tenancy model, adding the school column and policies, wiring the per-request session and writing the boundary tests is typically part of a first SaaS release rather than a bolt-on, and retrofitting it onto a single-school app is more involved than building it in from the start. The main risk of doing it yourself is relying on application filters alone and discovering a gap only when a school reports seeing another school's students. Enforce it in the database and test the boundary, and that whole class of incident goes away. Isolation is the correctness side of running many schools on one platform; the load side, when they all sit exams at once, is in surviving the exam-season concurrency spike.
How RAITHub would build this
Scope:
- A tenancy model with the school as the tenant: a school column on every scoped table and a membership model for users in more than one school.
- PostgreSQL row-level security with forced policies, and a per-request session set from the authenticated user.
- A deliberate, audited cross-school path for platform support, never an ambient bypass.
- A CI policy-coverage check and a cross-school IDOR test suite over students, grades and payments.
- An append-only audit log of cross-school and sensitive actions.
Timeline: designed into a new platform, this is part of a 4–6 week fixed-scope SaaS development release; retrofitting isolation onto an existing single-school app is typically a 2–4 week code rescue pass, scoped after an audit. You receive: automated isolation tests in CI, handover docs, and full IP under an NDA signed before detailed discussion. Each school's data stays in your own cloud account; development uses synthetic data. Next step: a free 15-minute technical audit, then a written fixed quote; RAITHub publishes no rates. See what RAITHub builds for education on the EdTech industry page, and book the free audit with how many schools you serve and whether users ever belong to more than one.
Frequently asked questions
How do I keep each school's data separate on one platform?
Put a school id on every tenant-scoped table, enable PostgreSQL row-level security with a policy that limits rows to the session's school, and set that session from the authenticated user on every request. Enforce it in the database, not in application code alone, and test the boundary on every build.
Is application-level filtering enough for multi-tenant EdTech?
No. Adding the school id to each query is necessary but fragile, because one forgotten filter, new endpoint or raw query leaks data. The database should refuse to return another school's rows even when the application forgets, which is what row-level security provides as a backstop.
Should I use one database per school or a shared schema?
For most multi-school products, a shared schema with row-level security is the right default: the database enforces isolation everywhere with less operational overhead. Database-per-school gives stronger physical separation but makes migrations and connection pooling harder, which suits a smaller number of larger tenants.
How do I handle a teacher who works at two schools?
Model membership as its own table, so one user record links to several schools with a role in each. Switching schools changes the session's school id, and every data row still belongs to exactly one school. Resolve the person's role from the active membership, not globally.
How can platform admins work across schools without breaking isolation?
Through a deliberate, logged support path that sets the active school and records a reason, not an ambient bypass flag. Log every cross-school action in an append-only audit trail, and never expose a "see all schools" query to tenant-facing code.
How do I prove the isolation actually works?
With tests that attack the boundary on every build: a CI check that no tenant table ships without a policy, and a cross-school suite proving one school, authenticated, cannot read or write another school's records by id. Isolation you have not tested is isolation you cannot rely on.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.