Theo Zourzouvillys

Field Note 34current

A resource-free 'bouncer' account: the single gateway to customer resources

By
Theo Zourzouvillys
Published
Tags
securityinfracloudawsmulti-tenancy

TL;DR

Route all access to customer resources — and anything reachable from the outside that can touch them — through roles in a single dedicated cloud account that holds no resources of its own: a “bouncer” account. Make it the only principal that customer-side trust allows in, and fence it both ways:

  • Inbound: every customer-resource trust policy trusts only the bouncer role (conditioned with SourceArn / SourceAccount / ExternalId and owner-pinning, per ZFN-10Field Note · currentZFN-10 — Pin the expected owner on cross-account resource calls (confused-deputy defense)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.Open ZFN-10 →). No other account or workload can reach customer resources directly.
  • Outbound: the bouncer role’s own policy explicitly denies acting on resources inside your org (aws:ResourceOrgIDIAM condition key: aws:ResourceOrgIDAn IAM policy condition key holding the AWS Organizations ID that owns the resource a request targets. Comparing it to your own organization's ID lets a policy allow or deny based on whether the target is inside or outside your org — the basis for a deny-inward rule.docs.aws.amazon.com ↗ equal to yours), with a tight allowlist for the few internal targets it genuinely needs. So even a tricked or compromised bouncer can only ever reach outward.

Because the account has no resources, there’s nothing in it to compromise, run code on, or pivot through — it’s a pure, audited identity hop. This collapses the confused-deputy surface to one narrow, governed gateway with a single audit trail.

Context

Any workload that holds credentials able to reach a customer’s resources is a confused-deputy risk (ZFN-10Field Note · currentZFN-10 — Pin the expected owner on cross-account resource calls (confused-deputy defense)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.Open ZFN-10 →): attacker-influenced input can trick it into acting on the wrong customer, and a compromise of it inherits its standing access to customer data. If many of your workloads can assume into customer resources directly, the trust surface is enormous and impossible to reason about — every workload is a potential deputy, every trust policy is a thing to get right, and a foothold anywhere is a foothold toward customer data.

Cross-account AssumeRole is the right primitive; pointing it at customer resources from everywhere is the wrong topology. You want the opposite: one place allowed to touch customer resources, controlled and audited to the hilt, with the rest of your estate unable to reach customers except through it. The cleanest way to express “one controlled principal” is a dedicated account whose only job is to hold that principal — and to hold nothing else, so there’s nothing in it to attack.

Recommendation

Make one resource-free account the sole gateway to customer resources, fenced in both directions.

  • Customer-side trust names only the bouncer role. Every trust relationship granting access to a customer resource — the role you assume for a customer, the resource policy on a customer-touching resource — trusts only a role in the bouncer account, conditioned per ZFN-10Field Note · currentZFN-10 — Pin the expected owner on cross-account resource calls (confused-deputy defense)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.Open ZFN-10 →. No other account or workload appears in that trust; adding one is a reviewed exception, not a local choice.

  • Workloads reach customers only through the bouncer. A workload that needs a customer resource assumes (or calls a narrow service that assumes) the bouncer role, which performs the access on its behalf. The bouncer is the one deputy: minimal, narrowly scoped, and its assumption tightly gated.

  • The account holds no resources — and enforce it. No compute, no data, no queues, no secrets beyond what the gateway role needs. A Service Control Policy denies creating resources, so “no resources” is structural, not a convention. An account with nothing in it has nothing to exfiltrate, nothing to run attacker code on, and nothing to pivot through.

  • Deny the bouncer from acting on your own org (defense in depth). Its job is to reach outside; it must never be usable against your own organization. The role’s identity policy carries an explicit deny on any operation whose target is inside your org (aws:ResourceOrgIDIAM condition key: aws:ResourceOrgIDAn IAM policy condition key holding the AWS Organizations ID that owns the resource a request targets. Comparing it to your own organization's ID lets a policy allow or deny based on whether the target is inside or outside your org — the basis for a deny-inward rule.docs.aws.amazon.com ↗ equals yours):

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "AllowPutObjectAnywhere",
          "Effect": "Allow",
          "Action": "s3:PutObject",
          "Resource": "arn:aws:s3:::*/*"
        },
        {
          "Sid": "DenyPutObjectInsideOurOrg",
          "Effect": "Deny",
          "Action": "s3:PutObject",
          "Resource": "arn:aws:s3:::*/*",
          "Condition": {
            "StringEquals": { "aws:ResourceOrgID": "o-xxxxxxxxxx" }
          }
        }
      ]
    }

    Allow broadly to the outside, then deny whenever aws:ResourceOrgID is yours. (If the gateway genuinely needs a specific internal target, carve it out narrowly with NotResource — kept tight and documented — but default to no carve-out.) Because an explicit Deny always wins, this holds even if a broader allow is added later — the inward block can’t be accidentally undone by a future grant. Apply the same allow-out / deny-inward shape to every action the role can perform, not just s3:PutObject.

  • Assume the bouncer via federated identity, not static keys (ZFN-9Field Note · currentZFN-9 — No long-lived cloud keys; workloads authenticate by federated identityNo static AWS or GCP keys anywhere — not in code, secret stores, or env. Workloads use their runtime's own identity and cross clouds by exchanging it (OIDC) for short-lived credentials via federation. Static keys are a documented carve-out only.Open ZFN-9 →), and keep applying the per-call owner-pinning of ZFN-10Field Note · currentZFN-10 — Pin the expected owner on cross-account resource calls (confused-deputy defense)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.Open ZFN-10 → at the bouncer→customer call — the gateway concentrates the deputy; it doesn’t excuse the per-call checks.

Consequences

Easier:

  • The confused-deputy and cross-tenant blast radius collapses to one governed gateway: exactly one principal can reach customer resources, and one account to harden.
  • Customer-side trust policies are simple and uniform (they trust one principal), and easier to audit.
  • All customer-resource access flows through one account — a single audit trail where every access is attributable.
  • A resource-free account has no blast radius of its own: compromising it grants only the conditioned, audited ability to make the gateway calls, not a foothold with data or compute.
  • Fenced both ways, the bouncer can neither be reached from your estate nor turned back against it.

Harder:

  • An extra hop: workloads chain through the bouncer instead of calling customer resources directly — latency, an assume-role step, and a shared library/service to do it well.
  • The bouncer role and account become a critical control point — high-value, on the availability path for all customer access; its permissions and assumption conditions need tight governance and review.
  • “No resources” and the deny-inward allowlist must be actively enforced and policed; the temptation to “just put one small thing here” or “just allow this one internal write” has to be refused, or the property erodes.

References

  • ZFN-10Field Note · currentZFN-10 — Pin the expected owner on cross-account resource calls (confused-deputy defense)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.Open ZFN-10 → — the confused-deputy defense this account structurally concentrates; per-call owner-pinning and trust conditions still apply at the gateway.
  • ZFN-9Field Note · currentZFN-9 — No long-lived cloud keys; workloads authenticate by federated identityNo static AWS or GCP keys anywhere — not in code, secret stores, or env. Workloads use their runtime's own identity and cross clouds by exchanging it (OIDC) for short-lived credentials via federation. Static keys are a documented carve-out only.Open ZFN-9 → — assume the bouncer role via federated identity, not static keys.
  • ZFN-2Field Note · currentZFN-2 — Engineering priority orderingWhen concerns conflict, prioritize security > correctness > availability > performance — and never trade a higher-ranked concern for a lower one. The rule binds the moment you must choose. Cite it instead of re-arguing it.Open ZFN-2 → — security-first ordering that justifies the extra hop and the dedicated account.
  • aws:ResourceOrgIDIAM condition key: aws:ResourceOrgIDAn IAM policy condition key holding the AWS Organizations ID that owns the resource a request targets. Comparing it to your own organization's ID lets a policy allow or deny based on whether the target is inside or outside your org — the basis for a deny-inward rule.docs.aws.amazon.com ↗ and AWS Organizations / SCP multi-account guidance.

Changelog

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