Isolating Tenants with Bucket Prefixes and Access Points

Default to one bucket with a tenants/<tenantId>/ prefix per tenant, enforced by IAM policy variables on the signing role; add an S3 Access Point per tenant when tenants need their own policy, network controls or auditing boundary; move a tenant to its own bucket (and its own KMS key) only when contracts, data residency or per-tenant deletion guarantees require it — and whichever model you choose, route every upload and download through a single function that derives the location from the verified tenant, never from request input.

Multi-tenant products store every customer’s uploads in object storage, and the worst bug they can have is one tenant reading or overwriting another’s files. Isolation can live in application code, in IAM, in the storage topology, or in encryption keys — and the right mix depends on how many tenants you have, what you have promised them, and how much operational weight you can carry. This page belongs to upload authorization and tenant isolation in backend validation and cloud storage architecture. Per-user scoping inside a tenant is covered in scoping upload keys per user with IAM policy variables.

When to use this approach

  • Several customers (tenants) upload files into storage you operate, and their data must never mix.
  • You are deciding how to lay out buckets for a new product, or tightening an existing shared bucket.
  • Some tenants have contractual requirements — their own encryption key, region, or provable deletion.

Prerequisites

  1. An authoritative tenant ID for every request, from your authentication layer.
  2. S3 (the same models exist on GCS with managed folders and on Azure with containers per tenant).
  3. @aws-sdk/client-s3, @aws-sdk/client-s3-control for Access Points, and permissions to manage bucket policies and KMS keys.
  4. An inventory of tenant requirements: residency, customer-managed keys, retention, deletion SLAs.

Three isolation models

Shared prefix, access point per tenant, and bucket per tenant In the shared-prefix model, one bucket holds tenants slash tenant ID prefixes and IAM policy variables enforce the boundary. In the access point model, each tenant has an access point with its own policy scoped to its prefix in a shared bucket. In the bucket-per-tenant model, each tenant has its own bucket, policy and KMS key. More isolation, more to operate shared prefix bucket: uploads tenants/t-acme/ tenants/t-globex/ tenants/… IAM ${tag} scoping any number of tenants access point per tenant ap-acme ap-globex shared bucket policy per tenant up to 10,000 per region bucket per tenant acme own key globex own key hard boundary bucket quotas, more ops Most products run the left model for everyone and the right model for a few regulated tenants.
Isolation strength and operational cost rise together; mix models rather than applying the heaviest to everyone.

Implementation

One resolver decides where a tenant’s data lives, so application code never builds bucket names or prefixes itself:

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";

type Placement =
  | { model: "prefix"; bucket: string; prefix: string }
  | { model: "access-point"; accessPointArn: string; prefix: string }
  | { model: "bucket"; bucket: string; prefix: string; kmsKeyId: string };

// Loaded from your tenant configuration table; never from request input.
const TENANTS: Record<string, Placement> = {
  "t-acme": { model: "prefix", bucket: "uploads-shared-eu", prefix: "tenants/t-acme/" },
  "t-globex": {
    model: "access-point",
    accessPointArn: "arn:aws:s3:eu-west-1:123456789012:accesspoint/ap-t-globex",
    prefix: "tenants/t-globex/",
  },
  "t-initech": {
    model: "bucket", bucket: "uploads-t-initech-eu", prefix: "",
    kmsKeyId: "arn:aws:kms:eu-west-1:123456789012:key/5b1f7c1e-0d2a-4c6e-9a41-3f2b8e7d6c10",
  },
};

const s3 = new S3Client({ region: "eu-west-1" });

export function placementFor(tenantId: string): Placement {
  const p = TENANTS[tenantId];
  if (!p) throw new Error(`no storage placement for tenant ${tenantId}`);
  return p;
}

export async function signTenantUpload(tenantId: string, userId: string, type: string, size: number) {
  const p = placementFor(tenantId);                                   // verified tenant only
  const key = `${p.prefix}users/${userId}/${randomUUID()}/source`;
  const target = p.model === "access-point" ? p.accessPointArn : p.bucket;   // SDK accepts AP ARNs as Bucket
  const url = await getSignedUrl(s3, new PutObjectCommand({
    Bucket: target,
    Key: key,
    ContentType: type,
    ContentLength: size,
    ...(p.model === "bucket" ? { ServerSideEncryption: "aws:kms", SSEKMSKeyId: p.kmsKeyId } : {}),
  }), { expiresIn: 900 });
  return { key, url, model: p.model };
}

console.log(await signTenantUpload("t-globex", "u-11", "image/png", 20480));

An access point policy that confines one tenant to its prefix, and a bucket policy that delegates access control to access points:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "AWS": "arn:aws:iam::123456789012:role/UploadSigner" },
    "Action": ["s3:PutObject", "s3:GetObject", "s3:AbortMultipartUpload"],
    "Resource": "arn:aws:s3:eu-west-1:123456789012:accesspoint/ap-t-globex/object/tenants/t-globex/*",
    "Condition": { "StringEquals": { "aws:PrincipalTag/tenantId": "t-globex" } }
  }]
}
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "DelegateToAccessPoints",
    "Effect": "Allow",
    "Principal": { "AWS": "*" },
    "Action": "*",
    "Resource": ["arn:aws:s3:::uploads-shared-eu", "arn:aws:s3:::uploads-shared-eu/*"],
    "Condition": { "StringEquals": { "s3:DataAccessPointAccount": "123456789012" } }
  }]
}

Line-by-line on the decisions that matter

  • placementFor from configuration. The tenant’s model, bucket, access point and key live in one table. Moving a tenant from shared prefix to dedicated bucket is a data migration plus one row change, and no call site needs to know.
  • Access point ARN as Bucket. SDK v3 accepts an access point ARN wherever a bucket name goes and signs for the access point endpoint; presigned URLs work the same way.
  • The tag condition in the access point policy. The access point narrows the prefix; the aws:PrincipalTag/tenantId condition ensures only sessions tagged for that tenant can use it. Either alone leaves a gap.
  • Delegating the bucket policy to access points. With s3:DataAccessPointAccount, the bucket allows any request that arrives through one of your account’s access points and leaves the real decisions to each access point policy. Keep direct bucket access for administrative roles only.
  • A KMS key per dedicated-bucket tenant. A customer-managed key per tenant lets you prove isolation cryptographically, lets the tenant revoke access by disabling the key, and makes deletion verifiable: schedule the key for deletion and every object becomes unreadable.

Choosing a model

Decision guide for tenant storage isolation If a tenant requires its own encryption key, region or provable deletion, give it a dedicated bucket. Otherwise, if it needs its own policy, network restrictions or separate audit trail, give it an access point. Otherwise use a prefix in the shared bucket with IAM policy variables. Start shared; escalate per tenant, by requirement own key, region or provable deletion? own bucket yes no own policy, VPC rules or audit trail? access point yes no prefix + IAM policy variables The same resolver serves all three, so a tenant can move down this list without code changes.
Requirements, not tenant size, decide the model; most tenants never leave the bottom box.

Operational costs of each model

Isolation is not free, and the costs show up in places that are easy to miss when choosing.

Shared prefix costs almost nothing to add a tenant — a row in a table — and every bucket-level setting (lifecycle rules, CORS, event notifications, replication, inventory) applies to all tenants at once. The trade-off is that per-tenant variations become prefix-scoped rules, and a mistake in a bucket-wide setting affects everyone.

Access points add one resource and one policy per tenant, which infrastructure-as-code handles well, and give each tenant a separately auditable path: CloudTrail data events name the access point, so “who touched tenant X’s data” is a filter rather than a prefix search. Lifecycle, events and replication remain bucket-level.

Bucket per tenant multiplies every bucket-level setting by the number of tenants. Each bucket needs its lifecycle rules, CORS, notifications, logging, replication and policies kept in sync, and there is a default account quota on buckets (raisable, but a signal of intent). Reserve it for the tenants whose contracts pay for it.

Resources to manage per tenant in each model Adding a tenant in the shared-prefix model adds one configuration row. In the access point model it adds an access point and its policy. In the bucket-per-tenant model it adds a bucket, bucket policy, CORS, lifecycle rules, notifications, logging and a KMS key. What one new tenant adds to your infrastructure shared prefix 1 config row access point row + AP + AP policy bucket per tenant bucket, policy, CORS, lifecycle, events, logs, KMS key Automate the heavy model completely, or it drifts: one tenant's bucket always ends up missing a rule.
The dedicated-bucket model is only safe when creating a tenant's bucket is a single automated step.

Isolation beyond the upload path

Uploads are only one of the paths that touch tenant data, and isolation fails wherever a path bypasses the resolver. Audit the others with the same question — “could this code read or write another tenant’s objects if given the wrong ID?” — and apply the same fix: derive the location from the verified tenant and run with credentials scoped to it.

Processing workers are the most common gap. A transcoder triggered by storage events receives a key and typically runs with a role that can read the whole bucket. Tag worker sessions with the tenant parsed from the event’s key prefix and scope their role the same way, so a worker handling tenant A’s event physically cannot write output into tenant B’s prefix.

Downloads and delivery must also be tenant-aware: signed URLs and signed cookies should be scoped to the tenant’s prefix, as in secure media delivery, and CDN cache keys must never collapse two tenants’ objects that happen to share a relative path.

Search and metadata indexes leak across tenants more often than storage does. Every query against the metadata store needs a tenant filter enforced below the application layer — row-level security in PostgreSQL is a good fit, as in how to index file metadata in PostgreSQL.

Configuration gotchas

AccessDenied through an access point that has the right policy. The bucket policy does not delegate to access points. Add the s3:DataAccessPointAccount statement, or explicitly allow the access point’s principals on the bucket.

Presigned URLs with an access point fail with SignatureDoesNotMatch. Older SDKs or custom signing code signed for the bucket host, not the access point host. Use SDK v3 with the ARN as Bucket and let it resolve the endpoint.

Cross-tenant listings. A support tool calls ListObjectsV2 on the shared bucket without a prefix and shows every tenant’s files. Give support tooling the same resolver and a role scoped by tenant tag, not bucket-wide read.

KMS AccessDenied on upload to a dedicated bucket. The signing role can write the bucket but cannot use the tenant’s key. The key policy must allow kms:GenerateDataKey for the role (and kms:Decrypt for readers).

Verification

# As a session tagged for t-globex, write through the tenant's access point: allowed.
aws s3api put-object --bucket arn:aws:s3:eu-west-1:123456789012:accesspoint/ap-t-globex \
  --key tenants/t-globex/users/u-11/test.txt --body test.txt

# Same session, another tenant's prefix through the same access point: denied.
aws s3api put-object --bucket arn:aws:s3:eu-west-1:123456789012:accesspoint/ap-t-globex \
  --key tenants/t-acme/users/u-1/test.txt --body test.txt
# An error occurred (AccessDenied)

# Direct bucket access by the signer role: denied (bucket delegates only via access points).
aws s3api put-object --bucket uploads-shared-eu --key tenants/t-globex/x.txt --body test.txt

Frequently Asked Questions

Is a prefix really isolation?

With IAM enforcing it — policy variables on the signing role, and no role with bucket-wide access used in request paths — yes, for most products. What prefixes do not give you is a separate encryption key, region or deletion boundary; those need dedicated buckets.

How do I migrate a tenant from shared to dedicated?

Create the bucket and key, copy the tenant’s prefix with S3 Batch Operations (re-encrypting under the new key), switch the tenant’s placement row, verify, then delete the old prefix. Because every call goes through the resolver, the switch is atomic from the application’s point of view.

What about GCS and Azure?

On GCS, managed folders with IAM conditions give prefix-level isolation, and a bucket per tenant with CMEK gives the hard boundary. On Azure, a container per tenant with RBAC scoped to the container, or a storage account per tenant for the strongest isolation.