---
id: 10
title: "Pin the expected owner on cross-account resource calls (confused-deputy defense)"
status: current
date: 2026-06-12
authors:
  - "Theo Zourzouvillys"
tags: [security, infra, cloud, auth]
summary: "Authority to call a resource isn't proof it's the one you meant. Any call crossing an account boundary must assert the expected owner: ExpectedBucketOwner on S3, aws:ResourceAccount conditions, validation of untrusted ARNs, plus inbound trust pinned with SourceArn/ExternalId."
supersedes: null
superseded_by: null
aliases: []
---

## TL;DR

Holding permission to perform an operation is **not** proof that the resource you're operating on is
the one you meant. A *confused deputy* is your own workload, using its legitimate credentials,
tricked into reading from or writing to a resource owned by **someone else** — because a bucket
name, ARN, account ID, or key reference reached it from untrusted input, stale config, or another
service and pointed at an attacker-controlled target.

The rule: **every authorized call that could cross an account or ownership boundary must assert the
expected owner**, in depth:

1. **Pass the owner-assertion parameter** the API provides — `ExpectedBucketOwner` (and
   `ExpectedSourceBucketOwner` / `ExpectedDestinationBucketOwner` on copy) for S3, the equivalent
   owner/account/registry parameter elsewhere — so the provider rejects the call if the target isn't
   owned by the account you expect.
2. **Bound resources with IAM condition keys** — `aws:ResourceAccount` and/or `aws:ResourceOrgID`
   (and GCP VPC Service Controls / project pinning) — so even an unasserted call can't reach outside
   your own org.
3. **Validate untrusted identifiers before use** — any bucket name, ARN, or resource path that came
   from request input, another service, or config is checked (account, partition, region, allowlist)
   before a call is issued; never pass an attacker-influenced ARN straight through.
4. **Pin inbound cross-service trust** — role trust policies that let a cloud service act on your
   behalf set `aws:SourceArn` / `aws:SourceAccount` (and `sts:ExternalId` for third-party role
   assumption) so the deputy can't be invoked on a forged source.

If the API genuinely offers no owner-assertion mechanism and no condition key applies, that's a
**carve-out** — document it at the use site and compensate (see [Carve-out](#carve-out)).

## Context

Federated, least-privilege workload identity ([ZFN-9](/zfn/9-no-long-lived-cloud-keys/))
makes workloads good at *authenticating* — proving who they are and getting scoped, short-lived
credentials. But authentication and authorization answer "may this workload perform this action?"
They do **not** answer "is the resource this action targets the one we intended?" That second
question is where the confused-deputy class of bug lives, and it's invisible to the credential
model: the call is perfectly authorized; it just lands on the wrong owner's resource.

The canonical shape: a workload accepts a bucket name (or object ARN, KMS key id, queue URL) as
input — from an API request, a webhook payload, a row written by another tenant's flow, or a config
value — and issues an S3/KMS/SQS call against it using its own credentials. If an attacker can
influence that identifier, they can point your deputy at a resource in **their** account. Depending
on the operation that means: you write data you believe is private into a bucket the attacker can
read; you read attacker-supplied content and treat it as trusted; you're billed for and logged as
the actor on their resource; or you leak the fact and content of the operation across an account
boundary. Cloud providers call this out explicitly and ship defenses precisely because it's easy to
get wrong.

There's a mirror-image, *inbound* version: services that assume one of your roles to act on your
behalf (object-storage → function triggers, pub/sub → a role, audit/config services). If the role's
trust policy only says "service X may assume me," a different customer can configure *their*
resource in service X to assume **your** role — the cross-service confused deputy. The fix is to pin
the trust to a specific source (`aws:SourceArn` / `aws:SourceAccount`), and for third-party
integrations to require a secret `sts:ExternalId`.

None of this is exotic, but it's the kind of defense that's reliably forgotten because the happy
path works without it — the call succeeds, tests pass, and the missing owner check only matters when
an attacker supplies a hostile identifier. A security-first ordering
([ZFN-2](/zfn/2-engineering-priority-ordering/)) says pay for it up front.

## Recommendation

**When a workload makes an authorized call to a cloud resource that could cross an account or
ownership boundary, assert the expected owner — defense in depth across the request, the policy, and
the code.** Concretely:

- **Use the API's owner-assertion parameter wherever one exists.** For S3 this is
  `ExpectedBucketOwner` on every operation that accepts it, and `ExpectedSourceBucketOwner` /
  `ExpectedDestinationBucketOwner` on `CopyObject`. For other services, use the analogous
  owner/account/registry scoping parameter (e.g. ECR `registryId`, account-qualified identifiers).
  The expected owner is a configured, trusted value — your own account id(s) — never derived from the
  same untrusted input that supplied the resource name.
- **Bound resources and sources with IAM condition keys.** Policies attached to your roles use
  `aws:ResourceAccount` and/or `aws:ResourceOrgID` to confine actions to resources your accounts/org
  own, and `aws:SourceAccount` / `aws:SourceArn` on resource and trust policies for cross-service
  calls. This is the backstop that holds even when an application-level assertion is missed. On GCP,
  the equivalent guardrails are **VPC Service Controls** perimeters and explicit project pinning.
- **Validate untrusted resource identifiers before the call.** Any bucket name, ARN, key id, queue
  URL, or resource path that originates outside the calling service is parsed and checked (partition,
  account, region, and an allowlist of permitted owners/names) before a call is issued. Don't forward
  an attacker-influenceable ARN into an SDK call unchecked, even when a condition key would also
  catch it.
- **Pin inbound cross-service trust.** Role trust policies that permit a cloud service to assume the
  role constrain it with `aws:SourceArn` / `aws:SourceAccount`; third-party cross-account role
  assumption requires a non-guessable `sts:ExternalId`. A bare "service may assume me" trust policy
  is a defect.

### Carve-out

Some APIs offer no owner-assertion parameter, and some call sites can't be covered by a
resource-account condition key (e.g. a deliberately cross-account integration). Where that's
genuinely the case, it's a **carve-out**, following the documented-at-the-use-site discipline in
[ZFN-3](/zfn/3-default-encrypt-internal-traffic/):

1. **Documented at the use site.** A comment where the call is made states that no owner-assertion
   mechanism exists for this API/operation, what compensating control is in place instead, and links
   this note.
2. **Compensated.** A concrete alternative defense is applied and named: code-level validation of the
   resource's account/owner against an allowlist, a scoping resource policy, a VPC-SC perimeter, or a
   network boundary. "No mechanism available" is never the same as "no check."
3. **Minimally scoped and reviewed.** The cross-owner reach is the least the integration needs.

A cross-account-capable call with neither an owner assertion nor a documented compensating control is
a defect, not an exception.

## Consequences

**Easier:**

- A whole class of cross-account data-exfiltration and data-poisoning bugs is closed by default,
  including the ones only an attacker-supplied identifier would ever trigger.
- The defense is layered, so a single missed application-level assertion is still caught by an
  org/account condition key.
- Audit improves: owner assertions and `aws:ResourceAccount`/`SourceArn` conditions make "this
  workload only ever touches our own resources" an enforceable, reviewable property.
- Inbound trust pinning closes the cross-service confused-deputy hole bare service-trust policies
  leave open.

**Harder:**

- More to remember at every call site — threading an expected-owner value and validating untrusted
  identifiers. Helper wrappers reduce but don't remove the burden.
- Condition keys can over-constrain: `aws:ResourceAccount` / `aws:ResourceOrgID` will break
  *intended* cross-account access unless explicitly carved out — which is the point, but it means
  legitimate cross-account flows must be surfaced and allowlisted.
- Genuinely cross-account integrations carry carve-out obligations rather than being free.
- Expected-owner values are configuration (your account ids); multi-account / multi-region topologies
  make "what is *our* account here?" something to wire correctly.

**New obligations:**

- New code that calls a cloud resource whose identity can be externally influenced asserts the
  expected owner (parameter where available; otherwise the carve-out). Reviewers treat a missing
  assertion like a missing authentication check.
- Role definitions default to bounding resources with `aws:ResourceAccount` / `aws:ResourceOrgID` (or
  GCP VPC-SC), and trust policies pin `aws:SourceArn` / `aws:SourceAccount` / `sts:ExternalId`.
- Untrusted resource identifiers are validated before use; passing one through unchecked is a defect.

## References

- [ZFN-9](/zfn/9-no-long-lived-cloud-keys/) — how a workload authenticates *to* a cloud; this
  note verifies the *target* of an authorized call is owner-verified. A call uses both.
- [ZFN-3](/zfn/3-default-encrypt-internal-traffic/) — the carve-out-with-documentation-at-the-use-site pattern reused here.
- [ZFN-2](/zfn/2-engineering-priority-ordering/) — security-first ordering that justifies the per-call-site cost.
- [AWS: the confused deputy problem & `aws:SourceArn`/`aws:SourceAccount`](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html); [S3 `ExpectedBucketOwner`](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-owner-condition.html); [IAM `aws:ResourceAccount` / `aws:ResourceOrgID`](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html).
- [GCP: VPC Service Controls](https://cloud.google.com/vpc-service-controls/docs/overview) for an ownership/perimeter equivalent.

## Changelog

- **2026-06-12**: First published as a Field Note.
