Skip to content

S3

13 min read · main part only; folded sections are optional

After this you can answer: How does a browser download a private ticket image directly from S3, and how do you debug an S3 403?

S3 is a regional AWS service outside our VPC. There is no security group to open; every request is an HTTPS call checked by IAM.

flowchart TB
lambda["Tickets Lambda"] -->|"writes QR PNG - tickets role s3:PutObject on tickets/*"| bucket["minitickets-dev-ticket-assets-account S3"]
lambda -->|"stores objectKey - dynamodb:PutItem, UpdateItem"| table["minitickets-dev-tickets DynamoDB"]
api["API service"] -->|"reads keys - task role dynamodb:Query"| table
api -->|"returns 120 s URL - sid session, ownership check"| browser["Browser"]
browser -->|"GET or PUT - signature, API role s3 permission, CORS, HTTPS only"| bucket
api -->|"checks and copies poster - s3:GetObject, s3:PutObject with AES256"| bucket
class bucket service
class lambda,api,browser app
class table aws

Legend: highlighted = S3; app = our code; AWS = managed service. Everything here exists in code.

Open the ticket assets bucket in the map →

Follow one ticket download.

  1. After a booking, the tickets Lambda draws a QR code and writes it with PutObject to tickets/<bookingId>/<seat>.png, with SSE-S3 encryption and Cache-Control: private, no-store. It stores that key, not a URL, in the ticket record. A key never expires; a URL does.
  2. You open “My tickets”. The API reads your ticket records from DynamoDB. For each one it checks the owner, the booking and that the key is exactly tickets/<bookingId>/<seat>.png. Stored data is input too.
  3. The API presigns a GET for that key. Presigning is local math: the SDK signs the request (method, bucket, key, expiry, chosen headers) with the API task role’s temporary credentials. It makes no call to S3.
  4. The browser sends that URL to S3. S3 recomputes the signature, checks the expiry, then evaluates permissions as if the API role made the request: the role’s IAM policy, the bucket policy, Block Public Access, and a KMS key policy if the object used one.
  5. S3 returns the bytes. A second URL for the same key adds response-content-disposition=attachment, so the browser saves a file named ticket-<bookingId>-<seat>.png.
  6. After 120 seconds a new request with that URL fails. So the page refetches the ticket list every 60 seconds and whenever the tab regains focus, getting fresh URLs before the old ones expire (use-my-tickets.ts).
sequenceDiagram
participant B as Browser
participant A as API service
participant D as Tickets DynamoDB
box rgb(237,233,254) Amazon S3
participant S as S3
end
B->>A: tickets.mine, sid cookie
A->>D: Query by userId, task role
D-->>A: Ticket records with object keys
A->>A: Check owner and exact key, sign locally
A-->>B: GET URLs valid for 120 s
B->>S: Signed GET over HTTPS
S->>S: Check signature, expiry, policies
S-->>B: QR PNG, private no-store

Legend: arrows show order; S3 never talks to the API during a download.

The words: a bucket is a named container in one Region; names are global, so ours ends in the account ID. An object is the bytes plus metadata (content type, encryption, ETag). A key is the object’s full name. A prefix is the start of a key, like tickets/. There are no real folders; the console only draws them from slashes. AWS: objects

Consistency. S3 has strong read-after-write consistency for every PUT, overwrite and DELETE. After a successful write, the next GET, HEAD or LIST sees it. Writing one key is atomic: a reader sees the old object or the new one, never half. S3 does not give transactions across several keys, or across S3 and a database. AWS: consistency model

That is why the poster flow orders its writes. It copies the checked upload to posters/<eventId>/<uuid>.png first, and writes that key to Postgres last. If the database write fails, an unused object is left behind: harmless. The other order would leave an event pointing at a missing poster.

Storage classes and lifecycle. Every object has a storage class. They trade storage price for retrieval fees, minimum durations and access time:

ClassRead accessMinimum durationMinimum billed size
StandardMillisecondsNoneNone
Intelligent-TieringMilliseconds (optional archive tiers need a restore)NoneObjects under 128 KB are never moved down
Standard-IAMilliseconds, per-GB retrieval fee30 days128 KB
One Zone-IAMilliseconds, one Availability Zone only30 days128 KB
Glacier Instant RetrievalMilliseconds, higher retrieval fee90 days128 KB
Glacier Flexible RetrievalRestore first: minutes to hours90 days40 KB metadata overhead
Glacier Deep ArchiveRestore first: hours180 days40 KB metadata overhead

AWS: storage classes. A lifecycle rule moves or deletes objects by age, filtered by prefix or tag. Our QR PNGs are small images (size not measured), so an IA class would bill each as 128 KB and save nothing. A ticket must also open instantly at the venue door, so Flexible Retrieval and Deep Archive are out.

Encryption: SSE-S3 versus SSE-KMS. Every new object is encrypted at rest. With SSE-S3 (AES256), S3 manages the keys and the only check is the S3 permission. With SSE-KMS, a key in AWS Key Management Service encrypts a per-object data key. Now every read needs kms:Decrypt, and every write needs kms:GenerateDataKey, on that key. The key policy must allow it as well as the caller’s IAM policy. We use SSE-S3 on purpose: the code comment in infra/src/tickets/storage.ts says it avoids a paid KMS key and decryption grants.

The KMS AccessDenied trap. A role has s3:GetObject on the right prefix and still gets AccessDenied. The object was written with SSE-KMS, and the role has no kms:Decrypt on that key, or the key policy does not trust the role’s account. Common ways in: the bucket default changed to SSE-KMS, so new objects need KMS but old ones do not; another account wrote the object with its own key; or a cross-account reader meets the AWS managed key aws/s3, whose key policy you cannot edit. Look at ServerSideEncryption and SSEKMSKeyId on the object before you touch S3 policies. With SSE-KMS at scale, also turn on S3 Bucket Keys, which cut KMS requests and the risk of KMS throttling. AWS: SSE-KMS

Object Ownership. BucketOwnerEnforced turns off ACLs (access control lists, the old per-object grants) and makes the bucket owner own every object, even ones another account uploads. Access is then decided by policies only. A request that sends an ACL such as public-read fails with AccessControlListNotSupported. AWS: Object Ownership

Versioning. With versioning on, an overwrite keeps the old version and a delete adds a delete marker, so mistakes can be undone. Old versions are still billed until a lifecycle rule removes them. Our bucket does not enable versioning, so an overwritten object is gone. AWS: versioning

At the time of writing; check the AWS docs. AWS values read on 2026-10-19; our values come from the code.

ItemValueSource
Largest object48.8 TiB (AWS also says “50 TB”); older answers say 5 TBAWS multipart limits
Multipart uploadUp to 10,000 parts, 5 MiB to 5 GiB each (last part may be smaller)AWS multipart limits
Request rateAt least 3,500 PUT/COPY/POST/DELETE and 5,500 GET/HEAD per second per partitioned prefix; scales up over timeAWS performance
Presigned URL lifetimeUp to 7 days; ends earlier when the signer’s temporary credentials expireAWS presigned URLs
Our URL lifetime120 s (ASSET_URL_SECONDS)apps/api/src/queries/assets.ts
Our poster limit2 MiB, PNG or JPEG, 4,096 px per side, 16 megapixelsapps/api/src/services/poster-image.ts
Our lifecycleAbort incomplete uploads under tickets/ after 1 day; expire poster-uploads/ after 1 dayinfra/src/tickets/storage.ts
Our CORS preflight cache120 s (maxAgeSeconds)infra/src/tickets/cors.ts

The API’s task role credentials are temporary, so the 7-day maximum never applies to us. A URL signed with credentials that expire in 10 minutes stops working in 10 minutes, whatever expiresIn says.

Built in code. No live AWS check was done for this page.

infra/src/tickets/storage.ts → createTicketStorage creates the bucket minitickets-dev-ticket-assets-<account-id> with all four Block Public Access settings, BucketOwnerEnforced, default SSE-S3, a bucket policy that denies s3:* when aws:SecureTransport is false, and the two lifecycle rules. There is no versioning and no event notification.

Three prefixes, three jobs:

PrefixWritten byRead by
tickets/Tickets Lambda (s3:PutObject on tickets/* only)API role (s3:GetObject on tickets/*), through presigned GETs
poster-uploads/Admin browser, through a presigned PUT signed by the API roleAPI role, to inspect
posters/API role, by copy after checksAPI role, through presigned GETs

A prefix is just text in a key. The isolation comes from the role policies that name each prefix. The API role’s s3:PutObject has a condition: s3:x-amz-server-side-encryption must be AES256. The API cannot write tickets, and the tickets Lambda cannot read posters.

Downloads: apps/api/src/services/tickets.ts → mine rejects any ticket whose stored key is not exactly tickets/<bookingId>/<seat>.png. apps/api/src/queries/assets.ts → signAssetGet refuses keys outside tickets/ and posters/, and signs with private, no-store and an inline or attachment disposition.

Uploads: apps/api/src/queries/posters.ts → signPosterPut signs a PUT for a server-made key, poster-uploads/<eventId>/<userId>/<uuid>. The exact Content-Type and Content-Length are part of the signature, so the browser cannot send a bigger file or a different type with it. apps/api/src/services/posters.ts → complete then trusts S3, not the browser. It reads the stored size and type with HEAD, reads at most 2 MiB with IfMatch on the ETag, checks the image header and dimensions, and copies with CopySourceIfMatch. An ETag is an identifier S3 gives each object version; matching it proves validation and copy saw the same bytes.

The exact connections, from the same data as the map:

From → toWhat happensWhat allows it
Browser → Ticket assets bucketdownloads a ticket PNG or uploads a poster with a presigned URL
  • url presigned URL signed with the api task role's credentials, valid 120 s (ASSET_URL_SECONDS)signAssetGet
  • resource-policy CORS rule: allowed origin = appUrl, methods GET and PUT, max age 120 screateAssetCors
Tickets Lambda → Ticket assets bucketonly when computeEnabled = truewrites the QR PNGs under tickets/
API ECS service → Ticket assets bucketonly when computeEnabled = truesigns GET and PUT URLs, and copies a checked upload to posters/
  • iam api task role: s3:GetObject on tickets/*, posters/*, poster-uploads/*; s3:PutObject on posters/* and poster-uploads/* only with x-amz-server-side-encryption AES256createIamRoles
  • resource-policy bucket policy: Deny every s3:* request when aws:SecureTransport is falsecreateTicketStorage

Every policy statement that touches the bucket:

WhoActionsPolicyWhy only this
API task roletickets/* onlys3:GetObjectidentity ReadTicketPngs createIamRolesA presigned ticket GET works with the signer's permissions, so the api role must be allowed to read the ticket PNG it signs.
API task roleposters/* and poster-uploads/* onlys3:GetObjectidentity ReadPosters createIamRolesThe api reads staged uploads to check them before publishing, and reads poster objects. This separate statement grants no ticket writes.
API task roleposters/* and poster-uploads/* onlys3:PutObjectif s3:x-amz-server-side-encryption = AES256identity WritePosters createIamRolesThe browser's presigned poster PUT and the copy to posters/ run with this role. It can never overwrite tickets/.
Tickets Lambda execution roletickets/* onlys3:PutObjectidentity createTicketRoleWrite the QR PNGs. It cannot read or touch posters.
All principalsthe ticket-assets bucket ARN and every object ARN under itDeny s3:*if aws:SecureTransport = falseresource minitickets-dev-ticket-assets-tls bucket policy createTicketStorageEvery caller, including anonymous requests, is refused over plain HTTP. This deny overrides an identity grant or a presigned URL; use HTTPS.
GitHub deploy rolethe bucket and its objectss3:*identity TicketAssets createCicdPulumi manages the bucket and its settings.
GitHub deploy roleResource *; all actions on all resourcesDeny *if aws:RequestedRegion != us-east-1identity DenyOtherRegions createCicdAn explicit Deny beats every Allow: requests to other region endpoints are refused. Global services such as IAM and CloudFront use us-east-1 endpoints and pass this condition.

Bucket policy versus IAM policy. An IAM policy is attached to a role and says what that role may do. A bucket policy is attached to the bucket and says who may do what to it. Within one account, an Allow in either one is enough, unless something denies it. An explicit Deny anywhere wins. That is why our TLS Deny in the bucket policy blocks even a perfectly signed URL sent over plain HTTP. Across accounts, both sides must allow: the caller’s IAM policy and the bucket policy. AWS: policy evaluation for S3

Block Public Access is a guard on top of both. It rejects ACLs and policies that would grant access to everyone, and ignores any that exist. It does not block signed requests from our roles, which is why presigned URLs work with it on. A presigned URL is a bearer link: anyone holding it can use it until it expires, so never log it or share it.

CORS, cross-origin resource sharing, tells a browser whether a page from one origin may read a response from another. Our rule allows GET and PUT from our web origin only, allows the content-type, content-length and x-amz-server-side-encryption headers, and exposes ETag. CORS is not access control: curl ignores it, and a valid signature is still required. AWS: CORS

Event notifications can send “object created” or “object removed” events to SQS, SNS, Lambda or EventBridge. Delivery is at least once and may arrive out of order. We do not use them. A future thumbnail Lambda triggered by posters/ must write somewhere else, or it triggers itself forever. AWS: event notifications

flowchart TB
err["S3 returns 403"] --> code{"Error code in the XML body?"}
code -->|"Request has expired"| fresh["Ask the API for a new URL"]
code -->|"SignatureDoesNotMatch"| sig["Method, key, signed headers or query changed"]
code -->|"AccessDenied"| enc{"Object uses SSE-KMS?"}
enc -->|"yes"| kms["Check kms:Decrypt and the key policy"]
enc -->|"no"| iam["Check role policy, bucket policy Deny, HTTPS"]
class err failure
class code,enc security
class fresh,sig app
class kms,iam security

Legend: red = the failure; amber = permission checks; app = fixes on the client side. Read the error code before changing any policy.

What you seeWhyFix
AccessDenied with Request has expiredThe 120-second URL, or the signer’s credentials, ran outGet a fresh URL from the API
SignatureDoesNotMatchThe browser changed something that was signed: method, key encoding, Content-Type, Content-LengthSend exactly what was signed; do not rebuild the URL
Browser console: “blocked by CORS policy”Origin, method or a request header is not in the CORS ruleCompare the preflight request with the rule; S3 itself may be fine
AccessDenied with correct S3 permissionsObject encrypted with SSE-KMS, no kms:Decrypt or key policy grantRead the object’s encryption fields; fix the KMS side
AccessControlListNotSupportedThe client sent an ACL header to a BucketOwnerEnforced bucketRemove the ACL; use policies

Never make a bucket public to “test” a 403. Turn on CloudTrail data events or S3 server access logs for the bucket when you need to see which principal was denied. AWS: troubleshoot 403

At 10x, presigned URLs already keep image bytes off the API. The API’s cost per ticket is one DynamoDB query and two local signatures. Measure that path, not S3.

At 100x, a sale day sends many people to “My tickets” at once. Per-prefix request rates are high and S3 adds capacity over time, but a sudden jump can return 503 Slow Down. The SDK retries with backoff. Our key design spreads load because every booking ID is a separate path under tickets/. If downloads dominate, put CloudFront in front with origin access control and signed URLs or cookies. That is a separate design with its own caching and auth rules, not a switch.

Cost becomes requests and data transfer out, not storage. Lifecycle rules matter once objects pile up: expire staged uploads (we do), and decide when old tickets may be deleted. That is a business rule we have deferred, because an event can be months away.

  • “S3 is eventually consistent.” Not since December 2020. Reads after writes are strongly consistent. Stale data you see is a browser or CDN cache.
  • “A presigned URL makes the object public.” It is one signed request using the signer’s own permission, for a short time. The bucket stays private.
  • “CORS protects my bucket.” CORS only tells browsers what pages may read. Signatures and policies protect the bucket.
  • “I have s3:GetObject, so I can read it.” Not if the object is SSE-KMS and you lack kms:Decrypt on its key.
  • “Prefixes are folders with their own permissions.” A prefix is text in a key. Permissions come from policies that name it.

Let admins upload event posters from the browser, without trusting the file.

The browser asks the API for an upload slot. The API checks the admin role, makes the key itself, and signs a PUT with the exact type and size. The browser uploads directly to a staging prefix. Then the API reads the stored object, not the request, checks the bytes, and copies it to a new key under posters/ with an ETag condition. Postgres gets the new key last. A lifecycle rule deletes staging leftovers after one day. This is exactly the built flow.

flowchart TB
admin["Admin browser"] -->|"presigned PUT, exact type and size"| staging["S3 poster-uploads/"]
staging -->|"HEAD and bounded GET, IfMatch ETag"| check["API checks stored bytes"]
check -->|"CopyObject, CopySourceIfMatch"| published["S3 posters/uuid.png"]
published -->|"key saved last"| pg["Postgres events.poster key"]
staging -->|"lifecycle, 1 day"| gone["Staging copy expires"]
class admin,check app
class staging,published service
class pg aws
class gone aws

Legend: highlighted = S3 prefixes in one private bucket; app = our code; all of this is built.

Why copy instead of using the uploaded object? The upload URL is still valid for up to 120 seconds. Someone holding it could replace the staging object after the check. The published key is new and the browser never had a URL to write it.

Can the API sign a URL for an object it cannot read?
Yes, signing is local. S3 then rejects the request, because it checks the signer’s permissions when the URL is used.
How do you revoke a presigned URL early?
There is no per-URL revoke. Keep lifetimes short, or remove the signer’s permission, which breaks every URL it signed. Rotating long-term keys also works when those keys signed it.
Why SSE-S3 and not SSE-KMS?
No extra key cost and no second permission system. SSE-KMS is worth it when you need key-level audit, separate key control or cross-account key policies.
Would versioning help us?
It protects against overwrites and deletes. Our ticket keys are written once, so the main risk is deletion; versioning plus a noncurrent-version lifecycle rule would cover it at extra storage cost.
Why not let the browser upload straight to posters/?
Then an unchecked file would be live at once, and a reused upload URL could replace a published poster. Staging plus copy separates untrusted and trusted objects.
  • Presigning is local; S3 checks the signer’s permissions when the URL is used.
  • Read-after-write is strong for single objects; nothing is atomic across S3 and a database.
  • SSE-KMS adds a second permission check: kms:Decrypt and the key policy.
  • An explicit Deny in the bucket policy wins, even over a valid signature.
  • CORS controls browsers, not access.
Advanced: Console, read-only CLI and presign details

Console: S3 → Buckets → minitickets-dev-ticket-assets-<account-id> → Permissions (Block Public Access, Object Ownership, bucket policy, CORS), Properties (default encryption, versioning, event notifications), Management (lifecycle).

Terminal window
aws s3api get-public-access-block --bucket "$BUCKET"
aws s3api get-bucket-ownership-controls --bucket "$BUCKET"
aws s3api get-bucket-encryption --bucket "$BUCKET"
aws s3api get-bucket-policy --bucket "$BUCKET"
aws s3api get-bucket-cors --bucket "$BUCKET"
aws s3api get-bucket-lifecycle-configuration --bucket "$BUCKET"
aws s3api head-object --bucket "$BUCKET" --key "$KEY" --query '{SSE:ServerSideEncryption,KmsKey:SSEKMSKeyId,Size:ContentLength,ETag:ETag}'

These only read. The last one shows a single object’s encryption before you blame S3 policies.

S3 checks expiry when a request starts. A download that began at second 119 can finish. A retry at second 121 fails.

The poster PUT signs content-type and content-length explicitly (signableHeaders). Browsers set Content-Length themselves from the file, so a file of a different size breaks the signature. JavaScript must not try to set that header.

Directory buckets (S3 Express One Zone) are a different bucket type for single-digit millisecond access in one Availability Zone, with their own auth (CreateSession) and naming. Everything on this page is about general-purpose buckets.

Answer these without scrolling up. If one feels hard, reread that section.

  1. What does S3 check when a presigned URL is used, and whose permissions count?
  2. Why can a role with s3:GetObject still get AccessDenied on one object?
  3. Why does our TLS Deny block a valid signed URL sent over HTTP?
  4. Why does poster publishing copy to a new key instead of reusing the upload?
  5. Why would Standard-IA not save money for our QR images?

Redraw it from memory: the tickets Lambda writing tickets/, the API signing after an ownership check, and the browser downloading from S3, with the permission on each arrow

Try this: open infra/src/compute/iam.ts, find the ticket-reads-and-posters policy and list which prefixes the API may read and write

Read this book online: https://book.theversehub.in/