backend · intermediate · 6d
Presigned upload flow
Direct-to-storage uploads via presigned URLs with size/content-type limits and a completion webhook that verifies the object actually arrived — so your API server never touches file bytes.
Deliverable
An upload flow where the client gets a presigned PUT URL (server-assigned UUID key, constrained content-type/size, short expiry), uploads directly to S3-compatible storage with no proxy, and the server confirms receipt by fetching ETag/size — with a dashboard showing upload success rate and webhook latency.
Milestones
0/5 · 0%- 01Issue a constrained presigned PUT
Issue a presigned PUT URL where every constraint is bound into the signature, not checked after the fact. The API generates a server-assigned UUID key (never caller-chosen), a short expiry (e.g. 60–300s), an allowed Content-Type (e.g. image/jpeg), and a max Content-Length (e.g. 5 MB) — all encoded in the presigned URL's policy/signature so S3 itself enforces them before writing a byte. A tampered URL (wrong content-type, oversize, expired) is rejected by storage with a 403/SignatureDoesNotMatch, not by application logic that fires after the bytes are already stored. This matters because the webhook fires after storage — if constraints are only checked there, a malicious client has already stored a script disguised as an image. Prove it: try uploading with a tampered content-type and an oversize payload and show storage rejects both before the webhook ever fires.
Definition of done- POST /uploads/presign returns a presigned PUT URL with server-assigned UUID key, short expiry (≤300s), fixed Content-Type and max Content-Length bound into the signature; tampered/oversize/expired URLs are rejected by storage (403), not by the API.
- The key is never caller-supplied; two presign calls produce different UUID keys and a caller cannot guess another user's key.
Feeds fromSelf-review
Show a tampered content-type and an oversize upload both rejected by storage (403) before the webhook fires. A senior reviewer checks the constraints are in the signature, the key is server-assigned UUID, and expiry is short (not 1h).
- 02CORS direct upload without proxy
Configure the bucket so the browser uploads directly with no bytes through your API server — proxying ties one handler thread per upload for the full transfer (100 concurrent 100 MB uploads at 10 MB/s = 1000 thread-seconds of I/O). Set a minimal CORS policy: the exact allowed origin(s) (not `*`), the specific headers needed (`Content-Type`, `x-amz-*`), a short `Access-Control-Max-Age` for the preflight cache, and the allowed method `PUT`. Verify the flow: the browser sends OPTIONS preflight, receives CORS headers, then PUTs directly to the presigned URL. No file bytes touch your API — the server only sees the presign request and later the completion confirmation. Explain why `*` on a public bucket with no cookies is not a vulnerability but still wrong on a private bucket where presigned URLs carry the authorization.
Definition of done- Bucket CORS allows only the app origin to PUT with Content-Type/x-amz-*; an OPTIONS preflight from the browser passes and the subsequent PUT goes directly to storage — verified that no file bytes traverse the API (check handler never sees the body).
- A request from a non-allowed origin fails CORS preflight; wildcard `*` is not used and max-age is short and documented.
Feeds fromSelf-review
Show the OPTIONS→PUT flow in DevTools Network and prove no bytes hit the API. A senior reviewer checks the CORS policy is minimal (exact origin, specific headers, no wildcard on private bucket) and asks why `*` would still be wrong there.
- 03Server-assigned keys and clobber prevention
Prevent key-clobber and replay abuse. If the caller chooses the object key (e.g. username or client-chosen filename), any authenticated user can overwrite another user's file by guessing the key, and can repeatedly PUT to the same key within the URL's TTL to replace a legitimate file after the webhook already confirmed it. Fix it: the presign endpoint generates a UUID key, stores a pending-upload record `{uploadId, key, expectedContentType, maxSize, createdAt, status:'pending'}`, and the completion webhook only marks the upload whose UUID matches. The pending record also holds the expiry window so you can reason about the race: a client could upload a valid file, pass verification, then re-PUT a different file to the same key before the URL expires. Mitigate by storing the expected ETag contract or by short expiry + one-time-use semantics (invalidate the pending record on first completion). Document the tradeoff: short expiry shrinks the race window but forces clients to re-presign more often.
Definition of done- Keys are server-assigned UUIDs stored in a pending-upload record; a client-supplied key is ignored/rejected and guessing another user's key is infeasible.
- Re-PUT to the same key within the presigned TTL after webhook confirmation is detected (pending record invalidated or ETag mismatch) and does not silently replace the confirmed file — the race window and mitigation are documented.
Feeds fromSelf-review
Show two presign calls produce different UUID keys and that a re-PUT after confirmation is rejected. A senior reviewer checks the pending-upload record exists, the race window is stated, and the mitigation (short TTL + invalidate on complete) is defended — not just 'use UUID'.
- 04Completion webhook with ETag verification
Verify receipt server-side — never trust the client's 'I uploaded' message. The completion webhook (or client-triggered POST /uploads/:id/complete) fetches the object's metadata from storage (HEAD Object → ETag + Content-Length) and confirms it matches the expected values from the presign contract (content-type, size within max, key matches pending record). The webhook is idempotent: a retry or duplicate delivery (network retry, S3 event fan-out) with the same uploadId is a no-op, not a double-process. Two failure modes matter: (1) substitution — HEAD shows a different ETag/size than expected because the client re-PUT a different file within the TTL window (see milestone 3); (2) ghost completion — the client reports completion but the object never arrived (HEAD 404). Both are rejected, never marked 'received'. Document why ETag verification breaks multipart uploads (composite ETag from part hashes) and what you'd do there instead (store part ETags or switch to S3 Multipart Complete checksum).
Definition of done- POST /uploads/:id/complete fetches HEAD Object and confirms ETag + size match the presign contract; ghost completion (HEAD 404) and substitution (ETag mismatch after re-PUT) are rejected and logged.
- Duplicate webhook delivery with the same uploadId is idempotent — second call returns the same result with no side effects, proven by firing the webhook twice in a test.
Feeds fromSelf-review
Show HEAD 404 rejected, ETag mismatch after re-PUT rejected, and duplicate webhook is a no-op. A senior reviewer checks the webhook never trusts the client body and asks how you'd handle multipart ETag.
- 05Load-test, observe, and work an incident
Prove it under production-like load and make it observable when it misbehaves. Load-test the full flow (presign → direct PUT → completion) with many concurrent clients (e.g. 50 parallel uploads, mixed valid/invalid, expired URLs, oversize attempts) and find the QPS where the presign endpoint or webhook — not the direct-to-storage PUT — is the bottleneck. Emit RED metrics (presign rate, presign error rate, completion rate, webhook verification duration p50/p99) and a trace span for the HEAD verification so the flag system's own latency is visible in the waterfall. Then work an incident: inject a flood of expired-URL retries or a burst of oversize uploads and watch the presign error rate spike while direct PUTs stay healthy. Detect it from your dashboard (not logs), mitigate (shorter expiry / size reject at presign / rate-limit presign), and write a 5-line post-mortem whose prevention is not 'increase storage'.
Definition of done- A sustained load test (≥50 concurrent uploads, mixed valid/invalid/expired) reports presign QPS, completion rate, webhook p50/p99, and a dashboard shows all four with the HEAD trace span visible.
- You reproduced an incident (expired-URL flood or oversize burst), detected it from the dashboard, mitigated it, and wrote a post-mortem naming root cause and a prevention that isn't 'increase storage'.
Feeds fromSelf-review
Paste the dashboard and post-mortem. A senior reviewer checks the bottleneck is attributed to presign/webhook (not direct PUT), the HEAD trace localized it, and the prevention addresses expiry/verification/rate-limit — not 'scale storage'.
Starter
fallowlone/skein-projects
projects/presigned-upload
- README.md
- src/presign.ts
- test/presign.test.ts
npx degit fallowlone/skein-projects/projects/presigned-upload presigned-upload Implement the stubs, then run the tests until they pass: bun test
Fork the repo and push your work — the grade workflow runs the suite plus static checks on your own runners.
Rubric
| Junior | Mid | Senior | |
|---|---|---|---|
| Presigned URL constraints | The API issues a presigned PUT with no constraints; a client can upload a 5 GB video where a 2 MB image was expected, or substitute an arbitrary content-type. | The URL is signed with an expiry, a fixed allowed Content-Type, and a max Content-Length; a tampered or oversize PUT is rejected by storage, not by the API, so enforcement is in the signature rather than in per-request application logic. | You address key-clobber abuse: a presigned PUT to a predictable key allows any authenticated user to overwrite another user's file. You fix this with server-assigned UUID keys (never caller-chosen) and reason about the race window between upload and completion — a client can upload a valid file, then replace it before the webhook fires by re-using the URL within its expiry. |
| CORS & direct-upload flow | The browser upload is proxied through the API server; every upload passes through application memory and blocks a request thread for the duration. | The S3 bucket CORS policy allows the browser origin to PUT directly; a preflight OPTIONS request passes cleanly, and no file bytes touch the API server. | You lock the CORS policy to the minimum necessary: the exact allowed origin(s), the specific headers needed (Content-Type, x-amz-*), and a short max-age for the preflight cache. You explain why a wildcard CORS policy on a public bucket is not a vulnerability (no cookies, no ambient authority) but why it is still wrong on a private bucket where presigned URLs carry the authorization. |
| Completion webhook & receipt verification | The client self-reports completion; the server trusts the report and marks the upload as received without verifying the object exists or matches what was requested. | The completion webhook fetches the object's ETag and size from storage and confirms they match the expected values; the webhook is idempotent so a duplicate delivery from a retry does not double-process. | You reason about the substitution window: between the presigned PUT expiry and the webhook firing, a race exists where a different file can be uploaded to the same key. You prevent this by storing the expected ETag (derived from the pre-upload contract) and refusing to mark a receipt as valid if the stored ETag differs — and you document the tradeoff: ETag verification catches substitution but breaks multipart uploads where the ETag is assembled from part hashes. |
Reference walkthrough (spoiler)
Why presigned PUT instead of proxying: proxying every upload through the API server ties one request-handler thread per upload for the full transfer duration. At 10 MB/s and a 100 MB file, that's 10 seconds of thread time per upload — a single server handling 100 concurrent uploads spends 1000 thread-seconds in I/O. Presigned URLs shift that I/O directly to object storage, which scales horizontally at negligible cost.
Content-type and size enforcement must be in the signature, not in the webhook: a webhook fires after the bytes are already in storage. If the constraints are checked only there, a malicious client can store a script disguised as an image and the application has already accepted it into its bucket. Encoding content-type and max-size in the presigned URL signature means storage itself enforces the policy before a single byte is written.
Server-assigned keys prevent clobber attacks: if the caller chooses the object key (e.g. their own username), they can overwrite any file they've previously uploaded or guess another user's key. A UUID generated by the server at presign time, stored in the pending-upload record, and verified in the webhook makes the key unguessable and the upload non-replayable to a different slot.
The completion race and ETag verification: the presigned URL is valid for its full TTL after it is issued. A client can upload a legitimate file, receive a passing ETag from the webhook, and then upload a different file to the same key within the URL's remaining TTL. Storing the expected ETag at presign time and refusing to accept a receipt that does not match it closes this window — at the cost of incompatibility with multipart uploads, which produce a composite ETag.
Make it senior
- Replace single-part PUT with S3 multipart upload for files over 5 MB: create, upload parts in parallel, and complete — with resumable state stored server-side and part-ETag verification instead of single ETag.
- Add server-side virus scanning on the completion webhook using a Lambda/Worker trigger before marking the upload as safe — with a quarantine state and async scan result callback.
- Add upload progress + resumable retry on the client: track bytes sent, retry failed parts, and show a progress bar that survives a page reload via IndexedDB.