Treat every tenant identifier from a URL, header, body, host name or token claim as a requested context, then establish authority on the server. Authenticate the actor, verify their current membership or machine delegation for the selected tenant, bind that verified tenant to the operation, and check the actual owner of each resource before access. Pass only the server-verified context to services and data queries. A format-valid ID, a signed token and a query predicate are useful for different reasons, but none alone proves that the caller may act for the requested tenant. If a person legitimately belongs to multiple tenants, let them select one and verify that selection on every operation; do not ban tenant IDs from requests as a substitute for authorization.
OWASP's Multi-Tenant Application Security Cheat Sheet explicitly describes client-supplied tenant IDs as selectors to verify against authenticated, current authority. Its Authorization Cheat Sheet calls for a permission decision on every request and on the specific object. The two checks solve different problems: may this actor use the account, and may they perform this action on this record?
Illustrative case: one changed header
Illustrative case, not a customer incident: Northstar Metrics lets users switch between Cedar and Harbor accounts. A browser sends X-Tenant-ID: cedar with GET /api/reports/R-40. Priya has current Cedar membership and may read R-40. She has no Harbor membership. A request using X-Tenant-ID: harbor should be denied before any Harbor content is read. Morgan belongs to both accounts, but has report-read rights only in Cedar; a valid Harbor membership should still not reveal Harbor report R-82.
The risky implementation copies the header into a database predicate:
tenant = request.header("X-Tenant-ID")
return reports.find(tenant_id=tenant, id=request.path.report_id)
Parameterizing that query can prevent SQL injection. It cannot establish that Priya is allowed to choose harbor. The filter faithfully scopes the query to whatever tenant the caller supplied. The issue is the provenance of tenant, not the syntax of the ID. OWASP's multi-tenant guide gives this same class of failure as a tenant-context injection risk.
A safer control flow, written as architecture-neutral pseudocode, makes the trust transition visible:
actor = verify_authenticated_principal(request.credential)
candidate = parse_tenant_selector(request.header("X-Tenant-ID"))
membership = load_current_membership(actor.id, candidate)
require membership.active
require membership.allows("report.read")
context = verified_context(actor.id, membership.tenant_id, membership.version)
report = load_report_scoped_to(context.tenant_id, request.path.report_id)
require report.stored_tenant_id == context.tenant_id
require can_read_report(context, report)
return serialize_only_approved_fields(report)
parse_tenant_selector can reject malformed values, but parsing is not the access decision. verified_context represents server-owned state for this operation; it must not be reconstructible from a raw client header by the next service. The membership version is one possible freshness mechanism, not a universal requirement or a replacement for a live decision. If the membership service is unavailable, return a temporary failure without a content read or job admission; do not treat the raw tenant ID as fallback authority. A documented, bounded authorization cache is a separate policy choice, not an accidental error path.
Trace every point where a selector becomes authority
Use this small provenance table while reviewing one route:
| Value | Who supplies it? | Valid use | Invalid promotion |
|---|---|---|---|
| Account slug in host or path | Browser or API client | Locate the account to check. | Treat a recognizable slug as proof of membership. |
X-Tenant-ID or body tenant_id |
Browser or API client | Choose among accounts the actor may use. | Feed directly into a content query or IAM session tag. |
| Token subject | Identity provider after signature and issuer checks | Identify the original actor. | Assume every tenant named elsewhere in the token is still authorized. |
| Token tenant or role claim | Issuer at token creation time | Candidate context or short-lived policy input under a documented freshness rule. | Treat an old claim as current membership after removal. |
| Queued job tenant field | Earlier application step | Find the persisted job and re-check its current delegated authority. | Let the worker substitute a different tenant or reuse an expired grant. |
| Stored resource owner | Authoritative store, after integrity checks | Determine the resource's actual tenant and parent. | Copy a caller-supplied owner field into a new record without validation. |
The exact authority source varies by system. The review question is whether its data is current enough for the promised revocation behavior. OWASP's policy and data distribution guidance recommends naming which system owns each attribute and how stale a decision may be. A cryptographically valid token can remain valid after a role changes unless the service has a way to observe that change before acting.
The second service can undo the first service's correct check
Consider an export request with three inconsistent selectors. All values and code in this continuation are synthetic and unexecuted:
POST /accounts/cedar/exports
X-Tenant-ID: cedar
body: {"tenant_id":"harbor", "report_id":"R-82"}
The API correctly authenticates Priya and verifies Cedar membership. It then forwards the JSON body to a worker. The worker trusts body.tenant_id, so the correct check at ingress never constrains the actual content read. This is a provenance failure across two services, not a missing UUID validator.
# Defective API: verified Cedar, forwarded unchecked Harbor
context = authorize(actor, path.account, "export.create")
queue.send(request.json)
# Defective worker
reports = store.for_tenant(message.tenant_id)
export(reports.get(message.report_id))
Choose one authoritative selector in the API contract. In this example it is the path account. A supplied header or body tenant that conflicts with it is a malformed request, rejected before job admission. Omitting tenant fields from the body entirely makes the contract simpler, but downstream authorization is still required.
The server constructs a new job record instead of forwarding the original payload:
context = authorize(actor, canonical(path.account), "export.create")
require_optional_selectors_match(request, context.account)
report = load_authorized_report(context, request.report_id)
job = persist_job(account=context.account,
report=report.id,
actor=context.actor,
action="export.summary")
queue.send({"job_id": job.id})
The worker treats job_id as a locator. It loads the authoritative job and report metadata using its reviewed service boundary, verifies their account and parent relationship, and obtains current authority or the explicitly approved delegation for that operation. It does not accept a replacement account or action from the message. A job ID itself is not a grant. Restrict queue producers and bind each admitted message to the persisted job/attempt; otherwise a party that can enqueue arbitrary known job IDs may trigger duplicate or unauthorized work even without changing the tenant.
For a synchronous network call, the same principle applies: authenticate the upstream service, preserve the original actor and approved action, and validate the target at the resource service. Signing a message protects its origin and integrity; it cannot make a copied client tenant correct. OWASP's policy distribution guidance makes that distinction explicit.
Work through the conflicting cases
| Input or state | Decision in this example | Reason |
|---|---|---|
Cedar path, Cedar header, no body tenant, R-40 |
Admit if Priya's report/export rights hold | One verified tenant and matching object |
| Cedar path/header, Harbor body tenant | Reject before creating a job | Ambiguous request must not reach a different parser's authority decision |
Cedar everywhere, Harbor report R-82 |
Deny object access | Consistent selectors can still name a foreign object |
| Morgan selects Harbor, has membership but no export right | Deny action | Account selection and action permission are separate |
| Worker receives a forged message naming Harbor | Reject or ignore unrecognized fields; no content read | Queue payload cannot broaden persisted authority |
| Legitimate job, grant removed before execution | Follow the declared revocation/delegation policy | Admission is not automatically permanent authority |
The expected outputs are a permitted job bound to Cedar, or no protected read and no admitted job. An error returned after the worker has already fetched Harbor data does not meet that boundary.
A data-layer restriction can catch a wrongly constructed Harbor key as a second defense. It still needs correct credentials, supported operations and key semantics. AWS's DynamoDB condition documentation describes LeadingKeys; inspect the real policy before claiming it constrains this application. A tenant attribute inside an item and a tenant-bearing partition key are different things.
To reject a spoofing alert, produce the exact chain: raw selector → current membership/action decision → server-created context → persisted resource comparison → final read. If the worker takes its tenant from raw JSON, the chain is broken even though the gateway check is impeccable. If the chain is intact in source but the deployed worker version is unknown, state that deployment limit precisely.
Remove ambiguous authority from service interfaces
The API repair is easier to maintain when an internal method cannot casually accept both verified context and an unrelated raw tenant. A signature such as export(context, tenant_id, report_id) invites the caller to provide inconsistent authority. Prefer an interface whose account comes from the verified operation context and whose resource lookup must match that account. The exact language type or framework mechanism is an implementation choice; the security property is the absence of an unchecked competing selector.
Document the exceptional callers. A support tool or migration may legitimately operate across accounts, but it needs a distinct, explicitly authorized path. Reusing the ordinary endpoint with a hidden skip_tenant_check flag makes its guarantees difficult to inspect. A separate interface can name the broader purpose, its allowed operator, and the records it may affect without weakening normal customer requests.
For the synthetic export worker, inspect both successful and failed admissions. A rejected conflicting selector should leave no job that later runs from the raw payload. A membership-service failure should not create an export with “pending authorization” if workers treat every queued record as approved. The durable job state and queue behavior must agree about what was actually authorized.
Make the repair observable without copying tenant data
A useful operational record can identify the request, the selected account's internal identifier, the decision category, and the admitted job identifier under the application's access policy. It does not need the report body or an entire authorization token. The reviewer should be able to connect a denied selector mismatch with the absence of a job, and a permitted request with the correct persisted account.
When debugging a mismatch, compare provenance rather than treating string equality as sufficient. Two fields that both say cedar can still originate from the same untrusted request. The important evidence is the current membership decision and the resource relationship established by the server. A log line repeating the client's account header cannot prove that decision occurred.
After the repair, have an authorized reviewer trace one permitted export and one conflicting-selector rejection from ingress to the final worker lookup. This is a proposed verification activity, not a claim that the pseudocode has been executed. If the worker deployment cannot be mapped to the repaired source, retain that limit even when the API source is now clear.
Choose one tenant-bearing API route this week and fill the provenance table from ingress to final data access. The next question is often whether a nested object truly belongs to its requested parent; use the parent-and-child ownership check. To review what happens after membership changes, use stale-access review.