S3 and R2 Object Storage skill for Claude Code
Private buckets, presigned uploads, no surprise bills. Claude keeps buckets private with Block Public Access and ACLs disabled, then serves files through short-lived presigned URLs or CloudFront with origin access control. Uploads go straight from the browser with server-chosen keys and enforced size limits, large files use multipart with exposed ETags, and lifecycle rules abort stale uploads and expire old versions on S3 or R2.
- name
s3-object-storage - size183 lines · ≈2,150 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Configures and uses S3 and S3-compatible storage such as Cloudflare R2 - private-by-default buckets, bucket policies, presigned GET, PUT and POST uploads, multipart uploads for large files, CORS for browser uploads, lifecycle rules, encryption, versioning, CDN delivery with CloudFront OAC, key design and conditional writes. Use when adding file uploads or downloads, generating presigned URLs, fixing S3 CORS or 403 errors, setting lifecycle or retention policies, or moving between S3 and R2. Try asking
- Let users upload profile images directly to S3 from the browser
- Our S3 presigned PUT fails with a CORS error. Fix it
- Our S3 bill keeps growing. Add sensible lifecycle rules
Use it when
- Adding file uploads or private downloads to an app
- Generating presigned URLs or browser multipart uploads
- Debugging S3 CORS errors or 403 responses
- Setting versioning, retention or lifecycle policies
- Moving storage between S3 and Cloudflare R2
Skip it when
- You need content validation and malware scanning — use secure-file-uploads
- The failure is an IAM permission question — use aws-iam
How to check the result
Anonymous requests return 403, presigned uploads succeed while oversized or expired ones fail, and lifecycle rules include abort-incomplete-multipart and noncurrent version expiry.
The common mistake it prevents
Letting the client choose the object key or using presigned PUT without any size check, which allows overwrites and unbounded uploads.
A correct storage setup keeps every bucket private with Block Public Access on, lets browsers upload and download only through short-lived presigned URLs or a CDN with origin access control, bounds upload size and type, cleans up incomplete uploads and old versions with lifecycle rules, and never routes large file bytes through the app server.
Workflow
- [ ] 1. Inventory: bucket settings (public access block, ownership, encryption, versioning, CORS, lifecycle, policy)
- [ ] 2. Decide the access path per use case (presigned, CDN, server-side)
- [ ] 3. Design keys (tenant/user prefix, no PII, unique names)
- [ ] 4. Implement presigned flows with size/type limits; multipart for large files
- [ ] 5. Configure CORS minimally for browser flows
- [ ] 6. Lifecycle, versioning and retention
- [ ] 7. Verify with curl and the CLI; confirm the bucket is not public
1. Inventory
B=acme-prod-uploads
aws s3api get-public-access-block --bucket $B
aws s3api get-bucket-ownership-controls --bucket $B
aws s3api get-bucket-encryption --bucket $B
aws s3api get-bucket-versioning --bucket $B
aws s3api get-bucket-policy --bucket $B --query Policy --output text | jq .
aws s3api get-bucket-cors --bucket $B
aws s3api get-bucket-lifecycle-configuration --bucket $B
Baseline for new buckets (defaults since 2023, verify for old ones): Block Public Access
all four settings on, Object Ownership BucketOwnerEnforced (ACLs disabled), default
encryption SSE-S3 or SSE-KMS. Use SSE-KMS with a customer-managed key when you need key
policies or audit of decrypts; enable S3 Bucket Keys to cut KMS request costs.
2. Access path
| Use case | Path |
|---|---|
| Users upload files from the browser or mobile | Presigned PUT (simple) or presigned POST (enforces size range); multipart presigned parts for > 100 MB |
| Users download private files | Presigned GET with short expiry, or CloudFront signed URLs/cookies for many files |
| Public assets (images, downloads) | CloudFront with Origin Access Control; bucket stays private |
| Server processes files | SDK with the workload’s IAM role; stream, do not buffer whole files in memory |
| Third-party account reads | Bucket policy with aws:PrincipalOrgID or a specific role ARN, or S3 Access Points |
3. Key design
tenants/{tenantId}/uploads/{yyyy}/{mm}/{uuid}.{ext}: prefix by owner for IAM scoping and lifecycle rules; random component avoids overwrites and enumeration.- Never put user-provided filenames or PII in keys; store the original filename in
your database or
Content-Dispositionmetadata. - Performance: each prefix supports at least 3,500 writes and 5,500 reads per second; S3 partitions automatically, so hashed prefixes are no longer needed for most workloads.
- S3 is strongly consistent for read-after-write and list. Use conditional writes
(
If-None-Match: *on PutObject) to prevent overwriting an existing key.
4. Presigned URLs (AWS SDK v3, Node)
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
const s3 = new S3Client({ region: "eu-west-1" });
// Download: 5 minutes, force download with a safe filename
const getUrl = await getSignedUrl(s3, new GetObjectCommand({
Bucket, Key, ResponseContentDisposition: `attachment; filename="invoice-${id}.pdf"`,
}), { expiresIn: 300 });
// Upload with enforced size and type: presigned POST
const { url, fields } = await createPresignedPost(s3, {
Bucket,
Key: `tenants/${tenantId}/uploads/${crypto.randomUUID()}.png`,
Conditions: [["content-length-range", 1, 10 * 1024 * 1024], ["eq", "$Content-Type", "image/png"]],
Fields: { "Content-Type": "image/png" },
Expires: 300,
});
Python (boto3): s3.generate_presigned_url("get_object", Params={...}, ExpiresIn=300)
and s3.generate_presigned_post(Bucket, Key, Fields=..., Conditions=[["content-length-range", 1, 10485760]], ExpiresIn=300).
Rules:
- The server decides the key; never let the client choose it.
- Presigned PUT cannot enforce a size range; use POST, or verify size after upload
(S3 event or
HeadObject) and delete violators. - Expiry: minutes, not days. A presigned URL is valid until the earlier of its expiry and the expiry of the credentials that signed it (role sessions often last 1-12 h), up to 7 days for SigV4.
- After upload, confirm via S3 event notification or a client callback plus
HeadObjectbefore marking the file as available; scan content if users share files (see secure-file-uploads). aws s3 presign s3://bucket/key --expires-in 300creates GET URLs only.
Multipart uploads
- Use for objects above about 100 MB; required above 5 GB per single PUT. Part size
5 MiB minimum (except the last part), maximum 10,000 parts: pick part size so
size / part_size <= 10,000. - Server-side:
@aws-sdk/lib-storageUploador boto3upload_file/TransferConfighandle parts, concurrency and retries. - Browser: server calls
CreateMultipartUpload, presignsUploadPartURLs per part, client uploads parts and returns ETags, server callsCompleteMultipartUpload. CORS must expose theETagheader. - Incomplete uploads are billed until aborted: every bucket gets an
AbortIncompleteMultipartUploadlifecycle rule.
5. CORS (browser uploads)
[{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedHeaders": ["content-type", "x-amz-*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}]
aws s3api put-bucket-cors --bucket $B --cors-configuration "{\"CORSRules\": $(cat cors.json)}"
Specific origins only; never * with credentials. A 403 on preflight usually means the
origin or a header is missing from the rule; a 403 on the actual PUT usually means the
signed headers (Content-Type) differ from what the browser sent.
6. Lifecycle and versioning
{
"Rules": [
{ "ID": "abort-mpu", "Status": "Enabled", "Filter": {}, "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 } },
{ "ID": "tmp-expire", "Status": "Enabled", "Filter": { "Prefix": "tmp/" }, "Expiration": { "Days": 1 } },
{ "ID": "old-versions", "Status": "Enabled", "Filter": {},
"NoncurrentVersionExpiration": { "NoncurrentDays": 30 } },
{ "ID": "archive-logs", "Status": "Enabled", "Filter": { "Prefix": "logs/" },
"Transitions": [{ "Days": 30, "StorageClass": "STANDARD_IA" }, { "Days": 180, "StorageClass": "GLACIER_IR" }] }
]
}
- Versioning protects against overwrites and deletes; pair with noncurrent-version
expiration or costs grow forever. Delete markers are cleaned with
ExpiredObjectDeleteMarker. - Transitions have minimum storage durations and per-object fees; objects under 128 KB are not worth transitioning to IA classes. Intelligent-Tiering suits unknown access patterns.
- Object Lock (compliance/governance) for regulatory retention; enable deliberately.
- Changing lifecycle rules that expire data, or deleting buckets/objects in bulk, requires explicit user confirmation.
Cloudflare R2 differences
- S3 API endpoint
https://<account_id>.r2.cloudflarestorage.com, regionauto; use the AWS SDK withendpointand R2 API tokens (access key ID + secret). - No egress fees; presigned GET/PUT work via SigV4. Presigned POST is not supported, so enforce size by checking after upload or proxying through a Worker.
- CORS and lifecycle rules are configured through the dashboard, Wrangler
(
wrangler r2 bucket cors,wrangler r2 bucket lifecycle) or the S3 API. - Public access via an R2 custom domain or the
r2.devURL (rate limited, dev only); Workers bindings (env.BUCKET.get/put) avoid presigning for Worker-served files. - Not every S3 feature exists (e.g. ACLs, Object Lock semantics, some storage classes); check compatibility for any advanced API before migrating.
Verification
aws s3api get-public-access-blockshows all four true; an anonymouscurl -I https://$B.s3.amazonaws.com/<key>returns 403.- Presigned upload with curl succeeds, an oversized POST fails with
EntityTooLarge, and an expired URL returns 403Request has expired. - Browser upload from the allowed origin works; from another origin the preflight fails.
- Lifecycle configuration includes abort-incomplete-multipart and noncurrent expiration
(
aws s3api list-multipart-uploads --bucket $Btrends to empty).
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.