this is a technical account of an explicitly authorized review of a multi-tenant education platform.
company and product names are omitted. people, tenant identifiers, and real records are replaced with placeholders, while reserved .example hosts and generic endpoint names stand in for the original network locations. none of the requests below are directly executable against a real system.
the central finding was not one broken route. it was an authorization model with no reliable principal.
several services accepted a tenant ID from the URL, an unsigned browser cookie, or no identity at all—and then treated that input as sufficient authority to read or change data. once that pattern appeared in one service, the next question was whether the same assumption had crossed into student records, exam systems, grading, and transport tracking.
it had.
the authorization model that emerged
the platform was split across several web services, but the same three decisions kept recurring:
- tenant context came from the request — a caller supplied a school or tenant ID in a path, body, or cookie
- authentication was absent or cosmetic — some endpoints accepted no session; another treated unsigned cookie values as a session
- object ownership was not checked — the server did not establish that the caller belonged to the selected tenant or could act on the selected record
documented path
Cross-tenant roster read
- Enumerate tenant identifiers
GET /tenantsThe public response exposed identifiers for tenants across the platform.
- Select an academic year
GET /tenants/<tenant_id>/yearsThe service accepted an arbitrary tenant ID without an authenticated principal.
- Request the student roster
POST /tenants/<tenant_id>/studentsThe response returned student identifiers, names, and class information for the selected tenant.
outcome: A caller outside the tenant boundary could reach student data.
- Enumerate tenant identifiers
documented path
Unauthenticated exam-result write
- Submit a synthetic result
POST /exam-resultsThe request used deliberately nonexistent exam and student identifiers and carried no session.
- Observe successful persistence
status: 1The service reported success rather than rejecting the unauthenticated or invalid objects.
- Repeat the same request
duplicate entryA duplicate response confirmed the first synthetic record had reached persistent storage.
outcome: The write boundary did not establish identity, tenant membership, or object validity.
- Submit a synthetic result
documented path
Answer-key exposure
- List exam metadata
GET /examsThe unauthenticated response exposed exam records from multiple tenants.
- Fetch one exam by identifier
GET /exams/<exam_id>The detail response included populated answer-key fields and the passing score.
outcome: Assessment confidentiality and integrity depended on an endpoint with no access control.
- List exam metadata
documented path
Client-controlled transport identity
- Send the request without cookies
POST /today-travelsThe service rejected the request because no tenant or user value was present.
- Supply unsigned identity cookies
tenant=<tenant_id>; user=<value>; role=<value>The caller selected the tenant and user context entirely from client-controlled values.
- Receive that tenant’s transport data
The response included route, vehicle, driver, schedule, occupancy, and location fields.
outcome: The service treated attacker-selected cookie values as authorization for safety-sensitive data.
- Send the request without cookies
these were separate services and separate data domains. the root cause was shared: the server accepted context before it established authority.
path one: tenant isolation failed before the data query
the student-information API exposed a tenant directory without authentication. that alone was not necessarily a vulnerability; public directories can be intentional. the dangerous part was that downstream routes accepted one of those identifiers without asking who the caller was.
the redacted sequence was:
GET https://records.example/tenants
GET https://records.example/tenants/<tenant_id>/years
POST https://records.example/tenants/<tenant_id>/students
Content-Type: application/json
{"yearId":"<year_id>"}
the final response returned student identifiers, names, and class information for the selected tenant. the observed behavior showed no effective caller-to-tenant or object-level authorization check between the supplied tenant ID and the returned data.
this is an insecure direct object reference, but the more useful diagnosis is missing tenant isolation. changing an object identifier did not bypass a single check; there was no principal against which the service could perform the check in the first place.
the correct server-side decision is not:
requested tenant = tenant from URL
it is:
authenticated principal
→ permitted tenant memberships
→ permitted role in selected tenant
→ permitted operation on selected object
tenant context can still appear in a URL. it just cannot be the source of truth for authorization.
path two: an exam write with no authenticated actor
the exam service accepted result writes without a session and without checking that either referenced object existed.
the write test used a deliberately synthetic identifier:
POST https://exams.example/exam-results
Content-Type: application/json
{
"examId": 999999999,
"studentId": 999999999,
"totalScore": 0,
"scores": []
}
the first request returned a success status. sending the identical request again returned a duplicate-entry error.
that second response mattered. it distinguished a superficial 200 OK from an actual write: the first synthetic record had been persisted far enough for a uniqueness constraint to reject the second.
no real exam or student record was modified, but the control failure was clear. the write path needed all of the following before storage:
- an authenticated actor
- a tenant derived from that actor’s signed session
- permission to submit or modify results
- an exam that exists inside the same tenant
- a student who exists inside the same tenant
- a rule connecting that student to that exam
- an audit record containing actor, object, before/after state, and request context
database constraints are the last line of defense. they are not a substitute for an authorization decision.
path three: assessment content crossed a role boundary
two exam-related services exposed catalog and detail routes without authentication. one detail route returned the structure of an exam. a separate grading route returned populated answer-key fields and passing scores.
the proof stopped at confirming that the sensitive fields were present:
{
"examId": "[REDACTED]",
"tenantId": "[REDACTED]",
"passingScore": "[PRESENT]",
"answerKey": "[PRESENT — VALUE NOT RETAINED]"
}
the distinction between exam metadata and exam secrets matters. a teacher may need titles, dates, and status. a student may need the start time. almost nobody should receive the answer key before grading is complete, and an unauthenticated caller should receive neither.
the safer response model is role-specific and stage-specific:
| caller | before exam | during exam | after grading |
|---|---|---|---|
| unauthenticated | no record | no record | no record |
| student | assigned metadata | permitted questions only | own result and allowed review |
| teacher | tenant-scoped exam | tenant-scoped management | tenant-scoped grading data |
| assessment admin | explicitly authorized content | explicitly authorized content | audit and export controls |
an answer key should not be serialized and then hidden by the frontend. it should be absent from the server response unless the principal and exam state permit it.
path four: unsigned cookies selected the transport tenant
the transport dashboard made the identity problem unusually visible.
without cookies, the service returned an unknown-tenant or unknown-user error. with caller-supplied cookie values, it returned data for the selected tenant.
POST https://transport.example/today-travels
Content-Type: application/json
Cookie: tenant=<tenant_id>; user=<value>; role=<value>
{}
the response schema included vehicle details, driver and supervising-staff fields, departure status, occupancy progress, and latitude/longitude fields.
during testing, the returned vehicles had not departed and the coordinates were zero. no live coordinate was collected. the authorization failure was nevertheless reproducible across a sample of tenant identifiers: changing an unsigned cookie changed the organization whose transport data the server returned.
this was not ordinary profile data. a live school-transport feed can reveal routes, schedules, vehicle identifiers, staff names, and the physical location of vehicles carrying students. that raises the required control level:
- signed, short-lived sessions
- server-derived tenant and role claims
- per-tenant authorization on every request
- narrower response fields by role
- immutable access logging for location reads
- alerting on cross-tenant enumeration and unusual query volume
- retention limits for precise location history
the browser can store a session identifier. it cannot be allowed to author its own identity.
the initial signal: a public application backup
the review began with a publicly downloadable application backup exposed from one of the platform’s application hosts.
the archive contained configuration files with connection strings and privileged database credentials for several database platforms. static analysis of bundled application assemblies also exposed the names of payment-related credential fields, tokens, and transaction tables.
no database connection was attempted. the evidence came entirely from the exposed archive and offline code inspection.
this finding was operationally separate from the authorization failures, but it pointed to the same missing-boundary problem: an internal deployment artifact had crossed into a public web root. the complete response should include:
- remove the archive and verify it is unavailable from every cache and origin
- rotate every credential and token that appeared inside it
- review authentication logs for use of those credentials before rotation
- inventory sibling archives and deployment leftovers across all hosts
- block archive, configuration, source-map, and backup patterns at build and deployment time
- move secrets into a managed secret store and eliminate them from shipped configuration
deleting the file is containment. rotation and review are remediation.
why these bugs clustered
the services looked independent, but their control assumptions formed a pattern:
| service decision | trusted input | missing control |
|---|---|---|
| select a tenant | path or body identifier | principal-to-tenant membership |
| select a transport user | unsigned cookies | signed session validation |
| read exam content | numeric object identifier | authentication, role, and exam-state policy |
| write an exam result | request body | actor, tenant, object, and relationship validation |
| publish an application build | web-root file placement | artifact policy and secret scanning |
this is why fixing only the reported routes is risky. if several teams copied the same trust model, there are probably more endpoints with different names and the same boundary failure.
the durable fix is a platform-level authorization contract:
authenticate once
derive tenant and role server-side
authorize every object operation
return only fields allowed for that role and state
record sensitive reads and all writes
deny when any relationship is missing
then make that contract difficult to bypass: shared middleware, typed policy helpers, default-deny service templates, and negative authorization tests in CI.
retest criteria
the remediation is ready for retest when these statements are all true:
- unauthenticated requests receive no tenant-scoped student, exam, grading, or transport data
- changing a tenant ID never changes data unless the signed principal belongs to that tenant
- changing client cookies cannot change the authenticated identity, tenant, or role
- exam-result writes reject nonexistent objects, cross-tenant objects, and unauthorized roles before storage
- answer-key fields are never returned to unauthenticated or student-role callers
- location endpoints require an explicitly authorized role and create an access event
- exposed archive credentials have been rotated, not merely removed from the web root
- automated tests cover cross-tenant reads, cross-tenant writes, role changes, stale sessions, and direct object enumeration
the most important negative test is simple:
given a valid session for tenant A, request an object belonging to tenant B and assert a denial without revealing whether the object exists.
run that test across every tenant-aware service, not just the endpoints named in a report.
disclosure chronology
- 9 June 2026 — initial contact reported the exposed application archive and asked for a secure reporting channel
- 10 June 2026 — the platform operator requested a broader authorized assessment
- 10 June 2026 — the detailed report documented the cross-tenant reads, unauthenticated exam APIs, synthetic result write, answer-key exposure, and client-controlled transport identity
the lesson is narrower and more durable: a tenant ID tells the server which boundary the caller wants. only an authenticated, authorized principal can tell the server whether the caller may cross it.