RisiAi Logo

RisiAi Consulting

AI Strategy & Implementation Expert

โ† Back to Architectures
Advanced Level Interactive

AWS S3 Multi-Tenant Architecture

Secure multi-tenant data isolation using S3 with IAM policies and prefix-based access control

Services

S3 IAM KMS CloudWatch

Use Case

Multi-Tenant SaaS

๐Ÿชฃ

S3 Multi-Tenant Blob Storage

Per-Client Data Isolation ยท CloudFront Distribution ยท Signed Access
S3 Prefix IsolationCloudFront + OACCognito / JWT AuthLambda@EdgeKMS per-tenant keysWAF + Shield
CloudFront Signed URLs or Signed Cookies grant time-limited, tenant-scoped access. Lambda@Edge validates the JWT, extracts the tenant ID, and generates signed URLs pointing to the tenant's S3 prefix. The client never accesses S3 directly.
1
Client / Tenant Layer
CLIENT
Tenant A โ€” Acme Corp
tenant_id: acme-001plan: enterprise
๐Ÿ“Š Dashboards, reports, media assets
๐Ÿ”’ Can only see /tenants/acme-001/*
CLIENT
Tenant B โ€” Globex Inc
tenant_id: globex-002plan: business
๐Ÿ“„ Documents, contracts, uploads
๐Ÿ”’ Can only see /tenants/globex-002/*
CLIENT
Tenant C โ€” Initech
tenant_id: initech-003plan: starter
๐Ÿ–ผ๏ธ Images, user-generated content
๐Ÿ”’ Can only see /tenants/initech-003/*
Client SDKs
Web (JS / React)Mobile (iOS / Android)Python (requests / boto3)CLI / API consumers
All clients authenticate via Cognito/OIDC, receive JWT with tenant_id claim.
JWT + tenant_id
2
Authentication & Tenant Identity
IDP
Amazon Cognito User Pool
Tenant-Aware Identity Provider
Custom attribute: tenant_idCustom attribute: tenant_planPre-Token Generation LambdaMFA: Enforced (TOTP)
Each user is linked to a tenant_id. Pre-token Lambda injects tenant claims into the JWT. Groups map to tenant roles (admin/viewer/uploader).
Token Structure
{
  "sub": "user-uuid-123",
  "custom:tenant_id": "acme-001",
  "custom:tenant_plan": "enterprise",
  "cognito:groups": ["tenant-admin"],
  "scope": "blobs:read blobs:write",
  "iss": "cognito-idp.us-east-1...",
  "exp": 1738500000
}
Cognito Identity Pool
STS Temporary Credentials
Authenticated RoleSession Tag: tenant_idScoped IAM Policy
Maps JWT tenant_id to STS session tag. IAM role uses ${aws:PrincipalTag/tenant_id} in policy conditions to restrict S3 access.
Tenant Registry (DynamoDB)
Metadata Store
PKTENANT#acme-001
planenterprise
storage_quota500 GB
kms_key_arnarn:aws:kms:...acme
cf_key_pair_idK2ABCDEF...
created2024-11-20
CloudFront
3
CloudFront Distribution & Edge Security
CDN
CloudFront Distribution
Global Edge Network
OriginS3 (OAC)
ProtocolHTTPS only (TLS 1.2+)
Cache PolicyTenant-Aware
Price ClassPriceClass_100
Custom Domaincdn.example.com
ACM Certificate*.example.com
Origin Access Control (OAC) โ€” only CloudFront can access S3. No direct S3 URLs exposed to clients.
EDGE
Lambda@Edge โ€” Viewer Request
Tenant Validation & URL Rewrite
# Lambda@Edge: Viewer Request
import jwt, boto3, time, hmac

def handler(event, context):
    request = event['Records'][0]['cf']['request']
    headers = request['headers']
    
    # 1. Extract & verify JWT
    token = headers.get('authorization',
      [{}])[0].get('value','').split(' ')[-1]
    claims = jwt.decode(token,
      algorithms=['RS256'], ...)
    
    tenant_id = claims['custom:tenant_id']
    
    # 2. Rewrite URI to tenant prefix
    original = request['uri']
    request['uri'] = (
      f'/tenants/{tenant_id}{original}'
    )
    
    # 3. Validate path (no traversal)
    if '..' in request['uri']:
        return {'status': '403'}
    
    return request
CloudFront Functions
Lightweight Edge Logic
URL normalizationSecurity headers injectionCache key customizationTenant-ID header injection
// Viewer Response: Security Headers
function handler(event) {
  var resp = event.response;
  resp.headers['x-tenant-isolated'] =
    {value: 'true'};
  resp.headers['x-frame-options'] =
    {value: 'DENY'};
  resp.headers['strict-transport-security'] =
    {value: 'max-age=31536000'};
  return resp;
}
WAF
WAF + Shield
Edge Protection
Rate limiting per tenant IPSQL injection / XSS rulesGeo-restriction (if required)Bot control managed rulesShield Advanced (DDoS)
WAF Web ACL attached to CloudFront. Custom rules for per-tenant rate limiting using JWT claims in headers.
SIGNED URL / COOKIE FLOW
1
Client authenticates
Cognito JWT
2
API generates signed URL
Lambda + CF key pair
3
URL scoped to tenant prefix
/tenants/acme-001/*
4
CloudFront validates signature
Trusted key group
5
S3 serves via OAC
Client never sees S3
OAC Only
4
S3 Bucket โ€” Multi-Tenant Storage
S3
Bucket Structure
Prefix-Per-Tenant Layout
s3://app-data-prod-us-east-1/
ย โ”œโ”€โ”€ tenants/acme-001/
ย โ”‚ย ย โ”œโ”€โ”€ documents/
ย โ”‚ย ย โ”œโ”€โ”€ media/
ย โ”‚ย ย โ”œโ”€โ”€ exports/
ย โ”‚ย ย โ””โ”€โ”€ uploads/
ย โ”œโ”€โ”€ tenants/globex-002/
ย โ”‚ย ย โ”œโ”€โ”€ documents/
ย โ”‚ย ย โ”œโ”€โ”€ media/
ย โ”‚ย ย โ””โ”€โ”€ uploads/
ย โ”œโ”€โ”€ tenants/initech-003/
ย โ”‚ย ย โ””โ”€โ”€ ...
ย โ””โ”€โ”€ _system/ metadata
POLICY
Bucket Policy (Isolation)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontOAC",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::app-data-prod/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn":
            "arn:aws:cloudfront::ACCT:
             distribution/E1ABC..."
        }
      }
    },
    {
      "Sid": "DenyDirectAccess",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": "arn:aws:s3:::app-data-prod/*",
      "Condition": {
        "StringNotEquals": {
          "aws:PrincipalServiceName":
            "cloudfront.amazonaws.com"
        },
        "ArnNotLike": {
          "aws:PrincipalArn": [
            "arn:aws:iam::*:role/AppBackend",
            "arn:aws:iam::*:role/AdminRole"
          ]
        }
      }
    }
  ]
}
IAM Policy (Tenant-Scoped)
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "s3:GetObject",
      "s3:PutObject",
      "s3:ListBucket"
    ],
    "Resource": [
      "arn:aws:s3:::app-data-prod",
      "arn:aws:s3:::app-data-prod/tenants/${aws:PrincipalTag/tenant_id}/*"
    ],
    "Condition": {
      "StringLike": {
        "s3:prefix": [
          "tenants/${aws:PrincipalTag/tenant_id}/*"
        ]
      }
    }
  }]
}
STS session tags from Cognito inject tenant_id dynamically. No hardcoded tenant references.
S3 Configuration
VersioningEnabled
EncryptionSSE-KMS (per-tenant key)
Public AccessBLOCKED (all 4 settings)
Object LockCompliance mode (optional)
LifecycleIA @ 90d ยท Glacier @ 365d
ReplicationCRR to DR region
Access Loggingโ†’ S3 log bucket
InventoryDaily CSV โ†’ Athena
KMS
Per-Tenant KMS Keys
acme-001arn:aws:kms:.../key-acme
globex-002arn:aws:kms:.../key-globex
initech-003arn:aws:kms:.../key-initech
S3 bucket key + per-tenant CMK. Key policy grants encrypt/decrypt only to the tenant's IAM role. Enables cryptographic tenant isolation.
Storage Quotas (per-tenant)
Enterprise500 GB
Business100 GB
Starter10 GB
Lambda checks S3 Inventory totals against DynamoDB quota. EventBridge triggers alert at 80% and blocks uploads at 100%.
API
Audit
5
Backend API Layer
API Gateway (REST)
Tenant-Scoped Endpoints
POST /blobs/upload โ†’ presigned PUT
GETย  /blobs/{key} โ†’ signed URL
GETย  /blobs โ†’ list tenant objects
DELย  /blobs/{key} โ†’ soft delete
Cognito authorizer extracts tenant_id. Lambda injects prefix before S3 operations. Rate limited per tenant.
UPLOAD
Upload Flow (Presigned URL)
# Lambda: Generate presigned upload URL
import boto3

s3 = boto3.client('s3')

def handler(event, context):
    tenant = event['requestContext']
      ['authorizer']['claims']
      ['custom:tenant_id']
    filename = event['queryStringParams']
      ['filename']
    
    key = f'tenants/{tenant}/uploads/{filename}'
    
    url = s3.generate_presigned_url(
        'put_object',
        Params={
            'Bucket': 'app-data-prod-us-east-1',
            'Key': key,
            'ServerSideEncryption': 'aws:kms',
            'SSEKMSKeyId': get_tenant_key(tenant),
            'Metadata': {
                'tenant-id': tenant,
                'uploaded-by': event[...]
            }
        },
        ExpiresIn=300  # 5 min
    )
    
    return {'statusCode': 200,
            'body': json.dumps({'url': url})}
DOWNLOAD
Download Flow (Signed CF URL)
# Lambda: Generate CloudFront signed URL
from botocore.signers import CloudFrontSigner
import rsa, datetime

def handler(event, context):
    tenant = event['requestContext']
      ['authorizer']['claims']
      ['custom:tenant_id']
    blob_key = event['pathParameters']['key']
    
    # Verify blob belongs to tenant
    full_key = f'tenants/{tenant}/{blob_key}'
    
    cf_url = f'https://cdn.example.com/{full_key}'
    
    expire = datetime.datetime.utcnow() + \
             datetime.timedelta(minutes=15)
    
    signer = CloudFrontSigner(
        KEY_PAIR_ID, rsa_signer)
    
    signed = signer.generate_presigned_url(
        cf_url, date_less_than=expire,
        policy=custom_policy(tenant))
    
    return {'statusCode': 200,
            'body': json.dumps(
              {'url': signed})}
Event-Driven Processing
S3 Event โ†’ EventBridgeThumbnail generation (images)Virus scan (ClamAV Lambda)Metadata indexing โ†’ DynamoDBQuota usage updateAudit log โ†’ CloudWatch
Every upload triggers event pipeline. Malware scan quarantines suspicious files. Thumbnails cached via CloudFront.
Observability
6
Monitoring, Audit & Cost Control
S3 Access Logging
Server access logs โ†’ log bucket
CloudTrail data events (S3)
Per-tenant access reports
Cross-tenant access detection
CloudFront Metrics
Cache hit ratio per tenant
4xx/5xx error rates
Bytes transferred per tenant
Real-time logs โ†’ Kinesis
Security Monitoring
GuardDuty S3 protection
Macie PII scanning
Security Hub findings
IAM Access Analyzer
Cost Allocation
S3 Storage Lens per prefix
Cost allocation tags (tenant_id)
CloudFront usage per behavior
Budget alerts per tenant
โš”
Tenant Isolation Matrix
LayerIsolation MechanismEnforcementBypass Risk
S3 Prefixtenants/{id}/ path partitionBucket policy + IAMLow
IAM Policy${aws:PrincipalTag/tenant_id}STS session tagsLow
KMS KeysPer-tenant CMK encryptionKey policy scopingVery Low
CloudFrontSigned URL / Cookie + OACKey pair + expiryLow
Lambda@EdgeURI rewrite to tenant prefixServer-side enforceLow
WAFPer-tenant rate limitingIP + JWT header rulesMedium
Object TagsTenantId tag on all objectsABAC condition keysLow
AuditCloudTrail + Access LogsDetection controlDetective only
โ˜…
Deployment Checklist
1
Create S3 Bucket
Enable versioning, block all public access, SSE-KMS default encryption. Create lifecycle rules for IA/Glacier tiering.
2
Set Up Cognito User Pool
Add custom:tenant_id attribute. Create pre-token generation Lambda. Configure app client with required scopes.
3
Deploy CloudFront + OAC
Create distribution with S3 OAC origin. HTTPS-only. Custom domain + ACM cert. Create key group for signing.
4
Deploy Lambda@Edge
Viewer Request function for JWT validation + URI rewrite. Viewer Response for security headers. Test with each tenant.
5
Configure Bucket + IAM Policies
Bucket policy allows only OAC + backend roles. IAM policies use PrincipalTag/tenant_id for dynamic scoping.
6
Create Per-Tenant KMS Keys
CMK per tenant with key policy scoped to tenant role. Enable automatic rotation. Register in tenant DynamoDB.
7
Build API Layer
API Gateway + Lambda for presigned upload/download URLs. Cognito authorizer. Rate limiting per tenant.
8
Attach WAF + Shield
Web ACL on CloudFront. Rate rules, SQL/XSS protection, bot control. Per-tenant throttling via custom headers.
9
Enable Event Pipeline
S3 notifications โ†’ EventBridge โ†’ virus scan, thumbnail gen, metadata index, quota tracking. DLQ for failures.
10
Enable Monitoring & Audit
S3 access logs, CloudTrail data events, GuardDuty S3 protection, Macie PII scan, Storage Lens per-prefix.
S3 Multi-Tenant Blob Storage ยท CloudFront OAC ยท Per-Client Isolation ยท Signed Access ยท Production Reference

Ready to Build?

This architecture can be customized for your specific needs. Let's discuss how to implement this pattern for your organization, or explore variations that better match your requirements.

Start a Project