AWS S3 skill for Codex

Private buckets, direct uploads, bounded storage bills. Codex applies a fixed S3 baseline (Block Public Access, ACLs disabled, encryption, TLS-only policy, versioning, multipart cleanup) and audits existing buckets against it. It wires direct browser uploads with server-chosen keys and presigned POST size limits, picks storage classes by access pattern, and builds event consumers that tolerate duplicates and URL-encoded keys.

  • Cloud Platforms
  • $aws-s3
  • 207 lines
  • 2 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- aws-s3

Installs to ~/.agents/skills/aws-s3/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $aws-s3, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Configures and uses Amazon S3 buckets safely - Block Public Access, Object Ownership, encryption with SSE-S3 or SSE-KMS bucket keys, TLS-only bucket policies, versioning, lifecycle rules and storage classes, presigned GET/PUT/POST URLs with size limits, CORS for browser uploads, and event notifications to Lambda, SQS or EventBridge. Use when creating a bucket, letting users upload or download files directly, cutting S3 storage cost, reacting to new objects, or auditing a bucket that might be public.

Use it when

  • Creating or hardening an S3 bucket
  • Implementing direct uploads or downloads with presigned URLs
  • Reducing S3 storage cost with lifecycle rules and storage classes
  • Triggering processing when objects land in a bucket

Use something else when

  • You are serving the bucket's content to the public through a CDN: use aws-cloudfront
  • The storage is Cloudflare R2 rather than S3: use cloudflare-r2

How to check the result

Bucket inspection shows all Block Public Access flags on, default encryption and a TLS-deny policy; unsigned requests return 403 and oversized or expired presigned uploads are rejected.

The mistake it prevents

Letting the client choose the object key in a presigned upload, which lets one user overwrite another user's files.

~/.agents/skills/aws-s3/SKILL.md

--- name: aws-s3 description: Configures and uses Amazon S3 buckets safely - Block Public Access, Object Ownership, encryption with SSE-S3 or SSE-KMS bucket keys, TLS-only bucket… license: MIT ---

Done well means: no bucket is public unless it serves public content through CloudFront, every object is encrypted and every request uses TLS, clients upload and download directly with short-lived presigned URLs, lifecycle rules keep storage cost bounded, and event consumers survive duplicates and out-of-order delivery.

Before changing anything

  1. Read AGENTS.md and find where buckets are defined (CDK, Terraform, SAM). Change IaC, not the console.
  2. Inspect the current state (needs network and credentials):
B=acme-uploads-prod
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 | python3 -m json.tool
aws s3api get-bucket-lifecycle-configuration --bucket $B
aws s3api get-bucket-notification-configuration --bucket $B

NoSuchPublicAccessBlockConfiguration or ServerSideEncryptionConfigurationNotFoundError are findings, not errors to ignore.

Baseline for every bucket

SettingValueWhy
Block Public AccessAll four flags on, at bucket and account levelDefault for new buckets; keep it
Object OwnershipBucketOwnerEnforced (ACLs disabled)Access only through policies
EncryptionSSE-S3 default; SSE-KMS with BucketKeyEnabled: true when you need key-level access control or auditBucket keys cut KMS request cost sharply
TLSBucket policy denying aws:SecureTransport = falseBlocks plaintext requests
VersioningOn for anything you cannot regenerateRecovery from overwrite and delete
LifecycleAbort incomplete multipart uploads after 7 days; expire noncurrent versionsOtherwise invisible storage grows forever
Access loggingServer access logs or CloudTrail data events for sensitive bucketsForensics
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "DenyInsecureTransport",
    "Effect": "Deny",
    "Principal": "*",
    "Action": "s3:*",
    "Resource": ["arn:aws:s3:::acme-uploads-prod", "arn:aws:s3:::acme-uploads-prod/*"],
    "Condition": { "Bool": { "aws:SecureTransport": "false" } }
  }]
}

Static websites: keep the bucket private and serve it through CloudFront with Origin Access Control (see $aws-cloudfront). Do not enable S3 website hosting with a public bucket policy unless the user explicitly wants that and accepts it.

Presigned URLs

Use presigned URLs so file bytes never pass through your API servers.

NeedUse
Download a private objectPresigned GET, 5-15 min expiry
Browser upload, size limit requiredPresigned POST with content-length-range
Browser or mobile upload, no size enforcement neededPresigned PUT with fixed ContentType
Files over ~100 MBMultipart upload with presigned part URLs
import boto3
from botocore.config import Config

s3 = boto3.client("s3", region_name="eu-west-1",
                  config=Config(signature_version="s3v4"))

url = s3.generate_presigned_url(
    "get_object",
    Params={"Bucket": "acme-uploads-prod", "Key": key,
            "ResponseContentDisposition": 'attachment; filename="report.pdf"'},
    ExpiresIn=900,
)

post = s3.generate_presigned_post(
    Bucket="acme-uploads-prod",
    Key=f"incoming/{user_id}/{uuid4()}.jpg",
    Fields={"Content-Type": "image/jpeg"},
    Conditions=[{"Content-Type": "image/jpeg"},
                ["content-length-range", 1, 10 * 1024 * 1024]],
    ExpiresIn=300,
)
# client sends multipart/form-data to post["url"] with post["fields"] then the file
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({ region: 'eu-west-1' });
const url = await getSignedUrl(
  s3,
  new PutObjectCommand({ Bucket: 'acme-uploads-prod', Key: key, ContentType: 'image/png' }),
  { expiresIn: 300 },
);
// the client must send exactly this Content-Type header or the signature fails

Rules:

  • Generate the key server-side. Never let the client choose the full key; it can overwrite other users’ objects.
  • The URL carries the signer’s permissions. Sign with a role that can only touch the upload prefix.
  • A URL signed with temporary credentials (Lambda, ECS task role) dies when those credentials expire, even if ExpiresIn is longer. SigV4 maximum is 7 days.
  • Treat uploads as untrusted: validate type and size after upload (event-driven scan or check) before using the object.
  • Use the regional endpoint for the bucket’s region; signing for the wrong region gives SignatureDoesNotMatch or a redirect.

Browser uploads need CORS on the bucket:

[{
  "AllowedOrigins": ["https://app.acme.com"],
  "AllowedMethods": ["PUT", "POST", "GET"],
  "AllowedHeaders": ["content-type"],
  "ExposeHeaders": ["ETag"],
  "MaxAgeSeconds": 3000
}]

Expose ETag for multipart uploads; the client needs each part’s ETag to complete.

Lifecycle and storage classes

Access patternClass
Unknown or changingS3 Intelligent-Tiering (no retrieval fees; small monitoring fee per object)
Read rarely, needed in millisecondsStandard-IA after 30 days
Archives read a few times a yearGlacier Instant Retrieval
Compliance archives, hours to restore is fineGlacier Flexible Retrieval or Deep Archive
{
  "Rules": [
    { "ID": "abort-mpu", "Status": "Enabled", "Filter": {},
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 } },
    { "ID": "logs-tiering", "Status": "Enabled", "Filter": { "Prefix": "logs/" },
      "Transitions": [{ "Days": 30, "StorageClass": "STANDARD_IA" },
                      { "Days": 180, "StorageClass": "GLACIER_IR" }],
      "Expiration": { "Days": 730 } },
    { "ID": "old-versions", "Status": "Enabled", "Filter": {},
      "NoncurrentVersionExpiration": { "NoncurrentDays": 30, "NewerNoncurrentVersions": 3 } }
  ]
}

Gotchas: IA and Glacier classes have minimum storage durations and minimum billable object sizes, so tiering millions of tiny objects can cost more than it saves. By default lifecycle does not transition objects smaller than 128 KB. Expiration rules on a production bucket delete data: confirm the prefix and day count with the user first.

Event notifications

NeedUse
One consumer, simpleS3 notification to SQS (then Lambda), not direct to Lambda, so you get buffering and a DLQ
Several consumers or content-based filteringEnable EventBridge on the bucket and use rules
Fan-out to many queuesS3 to SNS to SQS, or EventBridge
  • Delivery is at-least-once and not ordered. Deduplicate on bucket + key + versionId or the sequencer field.
  • Object keys in events are URL-encoded (spaces arrive as +). Decode with urllib.parse.unquote_plus before calling GetObject.
  • A consumer that writes back into the same bucket and prefix triggers itself. Write output to a different prefix excluded by the filter, or a different bucket.
  • Prefix/suffix filters on one bucket cannot overlap for the same event type.

Gotchas

  • S3 is strongly consistent for reads after writes and for lists. Old advice about eventual consistency is obsolete.
  • Prevent overwrites with conditional writes: PutObject with If-None-Match: * fails with 412 if the key exists. Use If-Match: <etag> for optimistic updates.
  • aws s3 sync --delete and aws s3 rm --recursive are destructive. Run with --dryrun first and get explicit confirmation before the real run. Deleting a bucket or disabling versioning on a production bucket also needs confirmation.
  • Request rates are per prefix (about 3,500 writes and 5,500 reads per second); spread hot keys across prefixes for very high throughput.
  • Cross-region transfer and internet egress cost money; NAT gateway processing for S3 traffic is avoidable with a free S3 gateway VPC endpoint.

Verification

  • Re-run the inspection commands; all four Block Public Access flags true, encryption present, policy denies insecure transport.
  • curl -I "https://acme-uploads-prod.s3.eu-west-1.amazonaws.com/some-key" without a signature returns 403.
  • Presigned flow: upload with the URL, check an oversized file is rejected, check the URL fails after expiry.
  • IAM Access Analyzer for S3 lists no unintended external or public access.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "AWS S3"
  short_description: "Private buckets, direct uploads, bounded storage bills"
  default_prompt: "$aws-s3 Let users upload profile images straight to S3 from the browser with a 10 MB limit"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem