Guide · AWS S3 · Multi-Tenant Architecture
S3 Access Points for Multi-Tenant MCP Servers
When you build a multi-tenant MCP server that stores per-customer data in S3, you face a common dilemma: separate buckets per tenant (clean isolation but operational complexity at scale) or one shared bucket (simpler lifecycle but a single bucket policy trying to express complex per-tenant rules). S3 Access Points solve this with a third option: one shared bucket, but separate access points per tenant, each with its own policy scoped to that tenant's prefix. This guide covers access point architecture for MCP servers, VPC-only access points for private MCP deployments, cross-account access patterns for enterprise customers, and how to structure access point policies to prevent cross-tenant data access. For S3 fundamentals see MCP Tools for AWS S3; for Object Lambda transformation see S3 Object Lambda.
TL;DR
S3 Access Points are named aliases to a bucket with their own ARN, endpoint hostname, and access policy. Each access point can restrict access to a VPC, a specific IAM principal, or a key prefix. For multi-tenant MCP: create one access point per tenant, scoped to data/{tenant-id}/. Tenant IAM roles get access to their access point only — no bucket policy changes needed when onboarding new tenants. Limit: 10,000 access points per bucket per region, which supports up to 10,000 tenants per shared bucket before you need to shard across buckets.
Access points vs bucket policies: the management problem
A bucket policy for a 3-tenant MCP server looks manageable. For 50 tenants, it becomes a 2,000-line JSON document that no human reviews carefully and that every change deployment must replace atomically. Access points solve this by delegating per-principal access rules to separate, independently-managed policies:
| Approach | Onboarding a new tenant | Revoking a tenant | Policy blast radius |
|---|---|---|---|
| Single bucket policy | Edit the bucket policy (redeploy entire policy) | Edit the bucket policy (redeploy entire policy) | One policy change can break all tenants if malformed |
| One bucket per tenant | Create bucket + policy + lifecycle rules (3 resources) | Delete bucket (after draining data) | Isolated — one broken bucket doesn't affect others |
| S3 Access Points (one per tenant) | Create access point + access point policy (2 resources) | Delete access point (data stays in shared bucket) | Isolated — one broken access point policy doesn't affect other tenants |
Access points combine the operational simplicity of a shared bucket (single lifecycle policy, single replication rule, single encryption setting) with the isolation of per-tenant policies. The bucket policy must explicitly delegate to access points with "Principal": "*", "Condition": {"StringEquals": {"s3:DataAccessPointAccount": "account-id"}} — without this delegation statement, access point policies can't grant access.
Creating per-tenant access points (CDK)
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as iam from 'aws-cdk-lib/aws-iam';
import { CfnAccessPoint } from 'aws-cdk-lib/aws-s3';
// Shared bucket with delegation policy
const dataBucket = new s3.Bucket(this, 'TenantDataBucket', {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
versioned: true,
});
// Bucket policy must delegate to access points — required for access points to work
dataBucket.addToResourcePolicy(new iam.PolicyStatement({
sid: 'DelegateToAccessPoints',
principals: [new iam.AnyPrincipal()],
actions: ['s3:*'],
resources: [dataBucket.bucketArn, dataBucket.arnForObjects('*')],
conditions: {
StringEquals: {
's3:DataAccessPointAccount': this.account,
},
},
}));
// Create access point per tenant — called at tenant onboarding time
function createTenantAccessPoint(
scope: cdk.Construct,
tenantId: string,
tenantRoleArn: string,
): CfnAccessPoint {
return new CfnAccessPoint(scope, `AP-${tenantId}`, {
bucket: dataBucket.bucketName,
name: `tenant-${tenantId}`,
policy: {
Version: '2012-10-17',
Statement: [
{
Sid: 'TenantReadWrite',
Effect: 'Allow',
Principal: { AWS: tenantRoleArn },
Action: [
's3:GetObject',
's3:PutObject',
's3:DeleteObject',
's3:ListBucket',
],
Resource: [
// Access points use arn:aws:s3:region:account:accesspoint/name syntax
// For ListBucket, the resource is the access point ARN itself
`arn:aws:s3:${scope.region}:${scope.account}:accesspoint/tenant-${tenantId}`,
// For object operations, append /object/* to the access point ARN
`arn:aws:s3:${scope.region}:${scope.account}:accesspoint/tenant-${tenantId}/object/data/${tenantId}/*`,
],
},
],
},
});
}
// Example: onboard two tenants
createTenantAccessPoint(this, 'acme-corp', 'arn:aws:iam::123456789:role/acme-mcp-role');
createTenantAccessPoint(this, 'widget-co', 'arn:aws:iam::123456789:role/widget-mcp-role');
Object key scoping: the access point policy restricts object operations to data/{tenant-id}/*. This means tenant ACME Corp can read and write data/acme-corp/file.json but cannot access data/widget-co/file.json even if they try to use the access point ARN with a cross-tenant key. The policy resource constraint enforces this at the IAM level, not at the application level.
MCP tool usage: in your MCP server, when a tenant's request comes in, look up their access point ARN from your tenant registry and use it as the Bucket parameter in S3 API calls. The access point ARN (arn:aws:s3:us-east-1:123456789012:accesspoint/tenant-acme-corp) is used directly — the S3 SDK resolves it to the correct endpoint automatically.
VPC-only access points for private MCP deployments
Enterprise MCP servers running inside a customer VPC should access S3 without traffic leaving the VPC. Combine S3 VPC endpoints with VPC-restricted access points:
// VPC endpoint for S3 (Gateway type — free, recommended for all VPCs)
const s3VpcEndpoint = vpc.addGatewayEndpoint('S3GatewayEndpoint', {
service: ec2.GatewayVpcEndpointAwsService.S3,
// Scope to specific route tables if you don't want all subnets to use the endpoint
subnets: [{ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }],
});
// Access point restricted to this VPC only
const privateAccessPoint = new CfnAccessPoint(this, 'PrivateAP', {
bucket: dataBucket.bucketName,
name: 'private-mcp-data',
vpcConfiguration: {
// Only requests originating from this VPC can use this access point
vpcId: vpc.vpcId,
},
policy: {
Version: '2012-10-17',
Statement: [{
Sid: 'VpcOnly',
Effect: 'Allow',
Principal: { AWS: mcpServerRole.roleArn },
Action: ['s3:GetObject', 's3:PutObject', 's3:ListBucket'],
Resource: [
`arn:aws:s3:${this.region}:${this.account}:accesspoint/private-mcp-data`,
`arn:aws:s3:${this.region}:${this.account}:accesspoint/private-mcp-data/object/*`,
],
// Double-enforce VPC origin (belt-and-suspenders with VpcConfiguration)
Condition: {
StringEquals: {
'aws:SourceVpc': vpc.vpcId,
},
},
}],
},
});
The vpcConfiguration field is the hard enforcement: any request to this access point that doesn't originate from the specified VPC returns AccessDenied at the S3 layer, before the access point policy is even evaluated. This prevents exfiltration of the access point ARN — even if an attacker obtains the ARN, they cannot use it from outside the VPC.
VPC-only access points are particularly valuable for MCP servers that handle regulated data (HIPAA, PCI DSS). The VPC network boundary is an auditable, infrastructure-level control that compliance reviewers understand — stronger than application-level "we only call S3 from within the VPC" claims.
Cross-account access points for enterprise customer data
Enterprise MCP customers often require their data to live in their own AWS account. Cross-account access points let your MCP server (in account A) read and write customer data in a bucket that lives in the customer's account (account B):
// In the CUSTOMER'S account (account B) — customer deploys this
const customerBucket = new s3.Bucket(customerStack, 'CustomerDataBucket', {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
});
// Customer creates access point and delegates to MCP provider account
const crossAccountAP = new CfnAccessPoint(customerStack, 'McpProviderAP', {
bucket: customerBucket.bucketName,
name: 'mcp-provider-access',
policy: {
Version: '2012-10-17',
Statement: [{
Sid: 'AllowMcpProvider',
Effect: 'Allow',
// MCP provider's execution role in account A
Principal: { AWS: 'arn:aws:iam::PROVIDER_ACCOUNT_ID:role/mcp-server-role' },
Action: ['s3:GetObject', 's3:PutObject'],
Resource: [
`arn:aws:s3:us-east-1:CUSTOMER_ACCOUNT_ID:accesspoint/mcp-provider-access`,
`arn:aws:s3:us-east-1:CUSTOMER_ACCOUNT_ID:accesspoint/mcp-provider-access/object/*`,
],
}],
},
});
// Customer bucket policy must also delegate to the access point
customerBucket.addToResourcePolicy(new iam.PolicyStatement({
sid: 'DelegateCrossAccount',
principals: [new iam.AnyPrincipal()],
actions: ['s3:*'],
resources: [customerBucket.bucketArn, customerBucket.arnForObjects('*')],
conditions: {
StringEquals: {
// Accept access point requests from both accounts
's3:DataAccessPointAccount': ['CUSTOMER_ACCOUNT_ID', 'PROVIDER_ACCOUNT_ID'],
},
},
}));
The customer controls their bucket policy and access point policy — they can revoke the MCP provider's access at any time by deleting the access point or removing the cross-account statement. This is a strong selling point for enterprise customers who need data sovereignty: they can provision access and revoke it without coordinating with the MCP provider. In your MCP server's tenant registry, store the customer-provided access point ARN; your code uses it directly.
Access point naming and discovery
Access point names must be unique within an account and region. For a multi-tenant MCP server, a consistent naming convention avoids collisions and makes tenant debugging straightforward:
// Convention: {service}-{tier}-{tenant-id}
// Examples:
// mcp-author-acme-corp (Author tier, tenant acme-corp)
// mcp-team-widget-co (Team tier, tenant widget-co)
// mcp-enterprise-megacorp (Enterprise tier, tenant megacorp)
function tenantAccessPointName(tier: 'author' | 'team' | 'enterprise', tenantId: string): string {
// Access point names: 3–50 chars, lowercase, letters/numbers/hyphens only
const sanitized = tenantId.toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 35);
return `mcp-${tier}-${sanitized}`;
}
function tenantAccessPointArn(
region: string,
accountId: string,
tier: 'author' | 'team' | 'enterprise',
tenantId: string,
): string {
return `arn:aws:s3:${region}:${accountId}:accesspoint/${tenantAccessPointName(tier, tenantId)}`;
}
Store the access point ARN in your tenant database at provisioning time. At runtime, your MCP server looks up the ARN for the current request's tenant and passes it as the S3 Bucket parameter. The lookup is a simple database read — no ListAccessPoints calls needed at request time.
Monitoring and auditing access point usage
CloudTrail logs every S3 API call, including which access point ARN was used. For a multi-tenant MCP server, this means per-tenant audit trails are built-in — every read and write is tagged with the access point name in CloudTrail, which encodes the tenant ID.
Useful CloudTrail queries for access point auditing:
-- CloudTrail Lake: find all S3 access to tenant acme-corp's data in the last 7 days
SELECT
userIdentity.arn AS caller,
requestParameters.bucketName AS bucket,
requestParameters.accessPointArn AS access_point,
requestParameters.key AS object_key,
eventName,
sourceIPAddress,
eventTime
FROM cloudtrail_logs
WHERE
eventSource = 's3.amazonaws.com'
AND requestParameters.accessPointArn LIKE '%mcp-author-acme-corp%'
AND eventTime > date_add('day', -7, now())
ORDER BY eventTime DESC
LIMIT 1000;
AliveMCP monitors access point health by probing S3 ListObjectsV2 through each active access point every 5 minutes. If a tenant's access point returns AccessDenied or NoSuchAccessPoint when the MCP server role calls it, AliveMCP raises an alert immediately — this means the tenant's tools silently fail on every S3 read/write, which would look like a mysterious tool error to the end user without this monitoring layer.