Skip to main content
Worked example

iOS account-switch review: a worked example

An account switch leaves an export job from the previous tenant behind. Examine the sample requests, the missing ownership checks and the expected fix.

iOS state, API authorization and export jobs

An iPhone application connected to API and evidence review stages
Illustration of the assessment workflow.

About this worked exampleFictional app and sample data. No live test, completed Nebula session or customer result is represented.

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. 1

    Create as Account A

    Export doc-alpha-01. The app stores job-alpha-01 while it waits for the file.

  2. 2

    Switch to Account B

    The screen changes accounts, but the pending job remains. Its next request uses B’s token.

  3. 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 Alpha
02 / 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.

Expected iOS and API retest outcomes for the fictional Field Notes app
ConditionCheckExpected result
A owns the record and exportCreate, poll and download as A202 creation; authorized status and 200 download remain available.
B requests A’s exportPoll and download job-alpha-01 as B404 from both endpoints; no job metadata or file bytes.
Same tenant, different ownerRead A’s job as a second Alpha member404 under this app’s owner-only export policy.
No export permissionUse an active account without exports:read403; authentication alone is insufficient.
Expired or revoked sessionRepeat either request with the old session401; no artifact delivery or sensitive response.
Create from another tenant’s recordRequest an Alpha document export as B404; no job or artifact is created.
Switch while a request is pendingSwitch A to B before A’s response arrivesOld response is discarded; B’s UI contains no Alpha data.
Cached file after account changeSwitch accounts, sign out and relaunchNo 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.