A stale export job survives an account switch. The app sends the new account’s token, but the server returns the previous account’s file. Follow the sample sequence from the iOS screen to the missing API check and the changes that would close it.
01 / The fictional app and its access rules
Field Notes lets a signed-in member export documents from their own tenant. An export is private to the member who requested it. Alpha and Beta are separate test tenants with no shared records or memberships.
- App
- Field Notes, a fictional document app
- Bundle / build
- com.example.fieldnotes / 1.0.0 (sample)
- API
- https://api.fieldnotes.invalid
- Account A
- member-alpha in tenant-alpha
- Account B
- member-beta in tenant-beta
- Controlled record
- doc-alpha-01: “Training record Alpha”
- Export
- job-alpha-01, requested by member-alpha
The sample assumes the app already has authorized test access and a controlled iOS build. It does not depend on guessing a job ID: Account A creates the job, and the test deliberately retains that known identifier across the switch.
02 / Follow the account-switch chain
- 1
Create as Account A
Export doc-alpha-01. The app stores job-alpha-01 while it waits for the file.
- 2
Switch to Account B
The screen changes accounts, but the pending job remains. Its next request uses B’s token.
- 3
Read the foreign job and file
Both API endpoints accept the job ID without checking its tenant and owner.
There are two separate defects in this example. The iOS app retains account-specific state, and the API trusts an authenticated caller with an object they do not own. Clearing the screen addresses the first; it cannot replace server authorization.
03 / Inspect the sample HTTP evidence
These exchanges are constructed sample data, not captured traffic. Token values are placeholders, the host uses the reserved .invalid domain, and the CSV contains one fictional training record.
01 / Owner creates and reads an export
Account A creates an export for its own record. The same account can read its job and file. This is the behavior the fix must preserve.
POST /v1/exports HTTP/1.1
Host: api.fieldnotes.invalid
Authorization: Bearer <ACCOUNT_A_TOKEN>
Content-Type: application/json
{"document_id":"doc-alpha-01"}
HTTP/1.1 202 Accepted
{"job_id":"job-alpha-01","state":"queued"}
# After the fictional worker completes:
GET /v1/exports/job-alpha-01/file HTTP/1.1
Host: api.fieldnotes.invalid
Authorization: Bearer <ACCOUNT_A_TOKEN>
HTTP/1.1 200 OK
Content-Type: text/csv
record_id,title
doc-alpha-01,Training record Alpha02 / Account B can read Account A’s job
After switching accounts, the app reuses the pending job identifier with Account B’s fresh token. In this deliberately vulnerable example, the status endpoint checks authentication but not the job’s tenant or owner.
GET /v1/exports/job-alpha-01 HTTP/1.1
Host: api.fieldnotes.invalid
Authorization: Bearer <ACCOUNT_B_TOKEN>
# Deliberately vulnerable sample response:
HTTP/1.1 200 OK
Content-Type: application/json
{
"job_id":"job-alpha-01",
"state":"ready",
"document_id":"doc-alpha-01",
"download_path":"/v1/exports/job-alpha-01/file"
}03 / The download repeats the same mistake
Account B follows the returned path and receives the controlled Alpha record. This second response demonstrates the example’s impact; an exposed job identifier alone would not establish file access.
GET /v1/exports/job-alpha-01/file HTTP/1.1
Host: api.fieldnotes.invalid
Authorization: Bearer <ACCOUNT_B_TOKEN>
# Deliberately vulnerable sample response:
HTTP/1.1 200 OK
Content-Type: text/csv
record_id,title
doc-alpha-01,Training record Alpha
# Expected after the ownership fix:
HTTP/1.1 404 Not Found
Content-Type: application/json
{"error":"not_found"}The job and download requests use the same Account B session. The owner’s successful response is the positive control; the foreign file response is the example’s authorization failure. No real severity rating or affected-customer count is assigned.
04 / Fix the server and the app
Bind every export operation to the authenticated principal
Authorize the source document before creating a job. Derive the tenant and owner from the validated server session, then apply that same policy to job status and file delivery. Never trust a tenant supplied in the request body to grant access.
// Illustrative server pseudocode, not a complete handler.
// principal comes from a validated, active server session.
async function ownedExport(principal, jobId) {
requirePermission(principal, "exports:read");
const job = await exportJobs.findOne({
id: jobId,
tenantId: principal.tenantId,
requestedBy: principal.subject
});
if (!job) throw notFound();
return job;
}
// Creation: authorize the document before enqueuing work.
const document = await authorizedDocument(principal, documentId);
await enqueueExport({
documentId: document.id,
tenantId: principal.tenantId,
requestedBy: principal.subject
});
// Both status and download must call the same policy.
const job = await ownedExport(principal, requestedJobId);
// Status returns only the authorized job's metadata.
// Download reads its server-bound private artifact.
return streamPrivateArtifact(job.artifactId, {
"Cache-Control": "private, no-store"
});This sketch omits framework plumbing. A real implementation must validate and revoke sessions, enforce action permissions, handle unfinished jobs and keep artifacts private. Do not expose an unauthenticated storage URL that bypasses the download check.
Reset account state and reject late responses
Cancel pending work, clear the export view and remove the previous account’s cached material when switching or signing out. Partition any retained cache by account and protect sensitive files with the appropriate iOS storage controls.
// Illustrative Swift-style session isolation.
@MainActor
func switchAccount(to next: Account) async {
let previousAccountID = currentAccount.id
sessionEpoch += 1
pendingRequests.cancelAll()
exportView.clear()
currentAccount = next
await exportCache.remove(for: previousAccountID)
}
@MainActor
func refreshExport(_ jobID: String) async throws {
let requestEpoch = sessionEpoch
let response = try await api.loadExport(jobID)
guard requestEpoch == sessionEpoch else { return }
exportView.show(response)
}A session-generation check prevents a delayed response from repainting the old account’s content. Apply account transitions atomically on the appropriate UI actor and repeat the same cleanup on sign-out. App-managed cleanup cannot revoke a copy the user previously saved elsewhere.
05 / Expected retest matrix
These are acceptance criteria, not recorded passes. This example uses 404 for unavailable or foreign objects, 403 for a missing action permission, and 401 for an invalid session. A real service should document and apply its policy consistently.
| Condition | Check | Expected result |
|---|---|---|
| A owns the record and export | Create, poll and download as A | 202 creation; authorized status and 200 download remain available. |
| B requests A’s export | Poll and download job-alpha-01 as B | 404 from both endpoints; no job metadata or file bytes. |
| Same tenant, different owner | Read A’s job as a second Alpha member | 404 under this app’s owner-only export policy. |
| No export permission | Use an active account without exports:read | 403; authentication alone is insufficient. |
| Expired or revoked session | Repeat either request with the old session | 401; no artifact delivery or sensitive response. |
| Create from another tenant’s record | Request an Alpha document export as B | 404; no job or artifact is created. |
| Switch while a request is pending | Switch A to B before A’s response arrives | Old response is discarded; B’s UI contains no Alpha data. |
| Cached file after account change | Switch accounts, sign out and relaunch | No previous-account content in the app; cache cleanup is verified separately. |
On a narrow screen, scroll the table sideways. Keyboard users can focus the table region and use the left and right arrow keys.
06 / What a real finding would need
- The authorized app build and environment, account roles, tenant ownership and exact test sequence.
- Redacted device captures and paired request/response records showing the owner control and the unauthorized effect.
- The deployed fix revision and observed results for each applicable retest, including failed or untested conditions.
Those records would support a completed assessment. This page provides a worked example of that reasoning and evidence structure; it is not a completed Nebula session or customer engagement.


