S3 and presigned URLs
After this you can answer: How can a browser fetch or upload a file without having AWS credentials?
Picture it
Section titled “Picture it”flowchart TB B["browser"] -->|"1. ask for a link"| A["api<br/>(checks the user)"] A -->|"2. sign with task role,<br/>120 s"| A A -->|"3. presigned URL"| B B -->|"4. GET or PUT, over HTTPS"| S["private S3 bucket"]How it works
Section titled “How it works”- The browser asks the api, for example “my ticket’s QR image”.
- The api checks that the user may see it, then signs a
GetObject(orPutObject) for one key, valid 120 s. Signing is local maths: no call to AWS. - The browser uses the link. S3 checks the signature and that the signer (the api task role) may do this. Then it serves or stores the file.
- The link expires. After 120 s S3 answers 403 “Request has expired”.
In MiniTickets
Section titled “In MiniTickets”The bucket minitickets-dev-ticket-assets-<account-id> is private: Block Public Access is fully on,
and ACLs are disabled (BucketOwnerEnforced). Its bucket policy denies every request that is
not over TLS (aws:SecureTransport false), for every principal.
| Prefix | Holds | Written by |
|---|---|---|
tickets/ | QR PNGs | the tickets Lambda (PutObject on tickets/* only) |
poster-uploads/ | browser uploads, deleted after 1 day | the browser, with a signed PUT |
posters/ | published posters | the api, copying a checked upload |
A poster upload is signed for one key, one content type and one size, and must send
x-amz-server-side-encryption: AES256: the api role allows PutObject only with that header. A
CORS rule (which origins a browser may call from) allows GET and PUT only from the app’s own origin.
| 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. |
See it in AWS
Section titled “See it in AWS”S3 → minitickets-dev-ticket-assets-<account-id> → Permissions (Block public access, Bucket
policy, CORS) and Management (Lifecycle rules).
aws s3api get-public-access-block --bucket minitickets-dev-ticket-assets-<account-id>
Break it
Section titled “Break it”| Symptom | Cause | Where to look |
|---|---|---|
| A ticket image link returns 403 “Request has expired” | Links live 120 s (ASSET_URL_SECONDS) | Ask the page for a new link |
| Poster upload fails with a CORS error | The page’s origin is not the allowed origin | Bucket → Permissions → CORS |
| The signed poster PUT returns 403 | The encryption header was left out | Browser network tab |
Say it in an interview
Section titled “Say it in an interview”Follow-ups
Section titled “Follow-ups”Can a presigned URL do more than its signer?
No. S3 checks the signer’s permissions when the link is used. If the api role lost s3:GetObject,
every link it signed would fail too.
Why such a short lifetime?
A presigned URL is a bearer link: whoever has it can use it. 120 s is enough to load an image and limits the damage of a leaked link. The trade-off is a new link per page view.
Why check the uploaded poster again?
The browser could upload anything to the signed key. The api reads the stored bytes, checks type,
size and dimensions, and only then copies it to posters/.
Advanced: Details: lifecycle and caching
Lifecycle rules delete poster-uploads/ after 1 day and abort unfinished uploads after 1 day.
Signed reads ask S3 to answer with Cache-Control: private, no-store, so no shared cache keeps a
ticket image (infra/src/tickets/storage.ts → createTicketStorage,
infra/src/tickets/cors.ts → createAssetCors).
Checkpoint
Section titled “Checkpoint”Answer these without scrolling up. If one feels hard, reread that section.
- Whose permissions does a presigned URL use?
- Does signing call AWS?
- What does the bucket policy deny?
- Which header must a poster upload send, and why?
- Why does an uploaded poster not go straight to
posters/?
Redraw it from memory: browser → api (check user, sign 120 s) → presigned URL → browser → S3 (checks signature and the signer's permissions)
Try this: on the map, select the ticket assets bucket and find the TLS Deny statement in its Permissions tab
Read this book online: https://book.theversehub.in/