S3
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?
Picture it
Section titled “Picture it”Where it sits
Section titled “Where it sits”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 awsLegend: highlighted = S3; app = our code; AWS = managed service. Everything here exists in code.
Open the ticket assets bucket in the map →How it works
Section titled “How it works”Follow one ticket download.
- After a booking, the tickets Lambda draws a QR code and writes it with
PutObjecttotickets/<bookingId>/<seat>.png, with SSE-S3 encryption andCache-Control: private, no-store. It stores that key, not a URL, in the ticket record. A key never expires; a URL does. - 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. - 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.
- 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.
- S3 returns the bytes. A second URL for the same key adds
response-content-disposition=attachment, so the browser saves a file namedticket-<bookingId>-<seat>.png. - 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-storeLegend: 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
Under the hood
Section titled “Under the hood”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:
| Class | Read access | Minimum duration | Minimum billed size |
|---|---|---|---|
| Standard | Milliseconds | None | None |
| Intelligent-Tiering | Milliseconds (optional archive tiers need a restore) | None | Objects under 128 KB are never moved down |
| Standard-IA | Milliseconds, per-GB retrieval fee | 30 days | 128 KB |
| One Zone-IA | Milliseconds, one Availability Zone only | 30 days | 128 KB |
| Glacier Instant Retrieval | Milliseconds, higher retrieval fee | 90 days | 128 KB |
| Glacier Flexible Retrieval | Restore first: minutes to hours | 90 days | 40 KB metadata overhead |
| Glacier Deep Archive | Restore first: hours | 180 days | 40 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
Limits and numbers
Section titled “Limits and numbers”At the time of writing; check the AWS docs. AWS values read on 2026-10-19; our values come from the code.
| Item | Value | Source |
|---|---|---|
| Largest object | 48.8 TiB (AWS also says “50 TB”); older answers say 5 TB | AWS multipart limits |
| Multipart upload | Up to 10,000 parts, 5 MiB to 5 GiB each (last part may be smaller) | AWS multipart limits |
| Request rate | At least 3,500 PUT/COPY/POST/DELETE and 5,500 GET/HEAD per second per partitioned prefix; scales up over time | AWS performance |
| Presigned URL lifetime | Up to 7 days; ends earlier when the signer’s temporary credentials expire | AWS presigned URLs |
| Our URL lifetime | 120 s (ASSET_URL_SECONDS) | apps/api/src/queries/assets.ts |
| Our poster limit | 2 MiB, PNG or JPEG, 4,096 px per side, 16 megapixels | apps/api/src/services/poster-image.ts |
| Our lifecycle | Abort incomplete uploads under tickets/ after 1 day; expire poster-uploads/ after 1 day | infra/src/tickets/storage.ts |
| Our CORS preflight cache | 120 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.
In MiniTickets
Section titled “In MiniTickets”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:
| Prefix | Written by | Read 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 role | API role, to inspect |
posters/ | API role, by copy after checks | API 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.
Connections
Section titled “Connections”The exact connections, from the same data as the map:
| From → to | What happens | What allows it |
|---|---|---|
| Browser → Ticket assets bucket | downloads a ticket PNG or uploads a poster with a presigned URL |
|
| Tickets Lambda → Ticket assets bucketonly when computeEnabled = true | writes the QR PNGs under tickets/ |
|
| API ECS service → Ticket assets bucketonly when computeEnabled = true | signs GET and PUT URLs, and copies a checked upload to posters/ |
|
Every policy statement that touches the bucket:
| Who | Actions | Policy | Why only this |
|---|---|---|---|
| API task roletickets/* only | s3:GetObject | identity ReadTicketPngs createIamRoles | A 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/* only | s3:GetObject | identity ReadPosters createIamRoles | The 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/* only | s3:PutObjectif s3:x-amz-server-side-encryption = AES256 | identity WritePosters createIamRoles | The browser's presigned poster PUT and the copy to posters/ run with this role. It can never overwrite tickets/. |
| Tickets Lambda execution roletickets/* only | s3:PutObject | identity createTicketRole | Write the QR PNGs. It cannot read or touch posters. |
| All principalsthe ticket-assets bucket ARN and every object ARN under it | Deny s3:*if aws:SecureTransport = false | resource minitickets-dev-ticket-assets-tls bucket policy createTicketStorage | Every 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 objects | s3:* | identity TicketAssets createCicd | Pulumi manages the bucket and its settings. |
| GitHub deploy roleResource *; all actions on all resources | Deny *if aws:RequestedRegion != us-east-1 | identity DenyOtherRegions createCicd | An 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
When it fails
Section titled “When it fails”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 securityLegend: red = the failure; amber = permission checks; app = fixes on the client side. Read the error code before changing any policy.
| What you see | Why | Fix |
|---|---|---|
AccessDenied with Request has expired | The 120-second URL, or the signer’s credentials, ran out | Get a fresh URL from the API |
SignatureDoesNotMatch | The browser changed something that was signed: method, key encoding, Content-Type, Content-Length | Send 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 rule | Compare the preflight request with the rule; S3 itself may be fine |
AccessDenied with correct S3 permissions | Object encrypted with SSE-KMS, no kms:Decrypt or key policy grant | Read the object’s encryption fields; fix the KMS side |
AccessControlListNotSupported | The client sent an ACL header to a BucketOwnerEnforced bucket | Remove 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 / 100x
Section titled “At 10x / 100x”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.
Common interview traps
Section titled “Common interview traps”- “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:Decrypton its key. - “Prefixes are folders with their own permissions.” A prefix is text in a key. Permissions come from policies that name it.
Design question
Section titled “Design question”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 awsLegend: 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.
Say it in an interview
Section titled “Say it in an interview”Follow-ups
Section titled “Follow-ups”Can the API sign a URL for an object it cannot read?
How do you revoke a presigned URL early?
Why SSE-S3 and not SSE-KMS?
Would versioning help us?
Why not let the browser upload straight to posters/?
Remember it
Section titled “Remember it”- 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:Decryptand the key policy. - An explicit Deny in the bucket policy wins, even over a valid signature.
- CORS controls browsers, not access.
Advanced
Section titled “Advanced”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).
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.
Checkpoint
Section titled “Checkpoint”Answer these without scrolling up. If one feels hard, reread that section.
- What does S3 check when a presigned URL is used, and whose permissions count?
- Why can a role with
s3:GetObjectstill getAccessDeniedon one object? - Why does our TLS Deny block a valid signed URL sent over HTTP?
- Why does poster publishing copy to a new key instead of reusing the upload?
- 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/