Article reading progress

authorization without a principal

an authorized review of a multi-tenant education platform where tenant IDs, unsigned cookies, and unauthenticated service APIs were treated as identity. the result was cross-tenant data exposure, exam-integrity failures, and a potential physical-safety risk.
published Jun 10, 2026· updated Jul 13, 2026· 8 min read· 1417 words
authorizationtenant-isolationidordata-integrityresponsible-disclosure
security details are written to explain the failure mode and the fix; operational targets and reusable secrets stay redacted.

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:

  1. tenant context came from the request — a caller supplied a school or tenant ID in a path, body, or cookie
  2. authentication was absent or cosmetic — some endpoints accepted no session; another treated unsigned cookie values as a session
  3. 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
exploit chainFour paths through the same missing boundaryEach path was verified with redacted or synthetic data during an authorized assessment.
  1. documented path

    Cross-tenant roster read

    1. Enumerate tenant identifiersGET /tenants

      The public response exposed identifiers for tenants across the platform.

    2. Select an academic yearGET /tenants/<tenant_id>/years

      The service accepted an arbitrary tenant ID without an authenticated principal.

    3. Request the student rosterPOST /tenants/<tenant_id>/students

      The response returned student identifiers, names, and class information for the selected tenant.

    outcome: A caller outside the tenant boundary could reach student data.

  2. documented path

    Unauthenticated exam-result write

    1. Submit a synthetic resultPOST /exam-results

      The request used deliberately nonexistent exam and student identifiers and carried no session.

    2. Observe successful persistencestatus: 1

      The service reported success rather than rejecting the unauthenticated or invalid objects.

    3. Repeat the same requestduplicate entry

      A duplicate response confirmed the first synthetic record had reached persistent storage.

    outcome: The write boundary did not establish identity, tenant membership, or object validity.

  3. documented path

    Answer-key exposure

    1. List exam metadataGET /exams

      The unauthenticated response exposed exam records from multiple tenants.

    2. Fetch one exam by identifierGET /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.

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:

callerbefore examduring examafter grading
unauthenticatedno recordno recordno record
studentassigned metadatapermitted questions onlyown result and allowed review
teachertenant-scoped examtenant-scoped managementtenant-scoped grading data
assessment adminexplicitly authorized contentexplicitly authorized contentaudit 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:

  1. remove the archive and verify it is unavailable from every cache and origin
  2. rotate every credential and token that appeared inside it
  3. review authentication logs for use of those credentials before rotation
  4. inventory sibling archives and deployment leftovers across all hosts
  5. block archive, configuration, source-map, and backup patterns at build and deployment time
  6. 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 decisiontrusted inputmissing control
select a tenantpath or body identifierprincipal-to-tenant membership
select a transport userunsigned cookiessigned session validation
read exam contentnumeric object identifierauthentication, role, and exam-state policy
write an exam resultrequest bodyactor, tenant, object, and relationship validation
publish an application buildweb-root file placementartifact 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.