Guide · Backend
File Upload API Design: Presigned URLs, Multipart and Resumable Uploads
25 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Good file upload API design keeps the file bytes away from your app servers. Your API hands out short-lived permission, the client sends bytes straight to object storage, a cloud file store such as Amazon S3, and your API confirms the result.
- A presigned URL is that permission: a storage link with a signature and an expiry time baked in. On Amazon S3 it can live from 1 second to 7 days, as of 26 October 2026.
- Big files go up in parts, called a multipart upload: up to 10,000 parts of 5 MiB to 5 GiB each, then one complete call. A resumable protocol such as tus lets a client ask how many bytes arrived and carry on.
- Never trust the file name, size or type the client claims. Put the size limit inside the signed permission, check the first bytes of the file, and keep new files in a quarantine area until a virus scan passes.
- Beginners can stop after the section on finishing and cancelling uploads. Engineers get the tested failure codes, the part-size maths, tus on the wire, the IETF draft that comes next and the virus-scan hand-off.
What goes wrong when users upload large files through your API server?
Your servers become a slow, expensive pipe, which is the first thing file upload API design must prevent. Every byte travels from the phone to your API and then again to storage. One dropped connection throws the whole upload away.
Picture a common setup. A coaching app lets teachers upload recorded lectures of 1 to 3 GB and lets students upload homework PDFs of a few MB. The app is made up, but the problems are real.
A teacher on a 10 Mbit/s home connection needs about 43 minutes to send a 3 GiB lecture (made-up speed, real maths). If the connection drops at 90 percent, a plain upload starts again from zero, so 2.7 GiB is sent twice.
Meanwhile your API server holds a connection open for 43 minutes, buffers data and carries every byte twice. This guide fixes three problems in turn: the slow pipe, the restart from zero and the files you cannot trust.
Which words do you need before designing file uploads?
Six words carry this whole guide. Each one gets a plain meaning here, tied to the coaching app.
- Object storage: a service that stores files as whole objects under a name called a key, such as incoming/teacher-3/lecture.mp4. Amazon S3 is the example used here; most clouds have a similar service.
- Presigned URL: a link to one storage action, signed with your server's secret key. Whoever holds the link can do that one action until it expires.
- Multipart upload: sending one big file as numbered pieces called parts, then asking storage to join them into one object.
- ETag: a short fingerprint that storage returns for each stored part or object. You hand the part ETags back when you finish, so storage knows which pieces to join.
- Offset: how many bytes of a file the server has safely received so far. A resumable upload continues from the offset instead of from zero.
- Quarantine: a separate area, such as a key prefix like incoming/, where new files wait until checks pass. Nothing in quarantine is shown to other users.
What is good file upload API design?
Good file upload API design splits an upload into three calls. The client asks your API to start, sends the bytes straight to storage, then tells your API it has finished.
Your API never carries the file bytes during the upload. It decides who may upload what, signs permission, records the upload in a database, and checks the result at the end. Storage does the heavy lifting it was built for.
The three-call upload flow
Drag to exploreNotice that the big arrow, the file itself, goes from the phone straight to object storage. Your API only handles the small start and finish messages.
Diagram connections:
- 1. Start upload to 2. Check and sign (POST /uploads)
- 2. Check and sign to 3. Send the bytes (signed URLs)
- 3. Send the bytes to 4. Complete (all parts sent)
- 4. Complete to 5. Verify and record (POST /uploads/ID/complete)
- 1. Start upload: Phone → API: name, size, type
- 2. Check and sign: API → Phone: key and presigned URLs
- 3. Send the bytes: Phone → Storage: parts, straight in
- 4. Complete: Phone → API: I am done, here is the ID
- 5. Verify and record: API → Storage: sizes, join parts, save row
Here is what the start call might return for a lecture. The API picked the key, the part size and the expiry; the client picked nothing that matters for security.
{
"uploadId": "upl_7f3a",
"method": "multipart",
"key": "incoming/teacher-3/0b9c-lecture.mp4",
"partSize": 16777216,
"partUrls": [
"https://STORAGE-HOST/...part 1 URL...",
"https://STORAGE-HOST/...part 2 URL..."
],
"expiresInSeconds": 3600
}Keep an uploads table in your own database with the key, the owner, the declared size, the state and the created time. Storage knows about bytes; only your database knows which upload belongs to which teacher.
How do presigned URLs let clients upload straight to storage?
A presigned URL carries its own permission. Your server signs the bucket, the key, the action and an expiry time with its secret key, and storage checks that signature on every request.
Think of it like a parcel locker code. The shop gives you a code that opens one locker, for one delivery, until a set time. You never get the key to the whole locker bank.
Where the picture breaks: a locker code usually works once, but a presigned URL works as many times as you like until it expires. The S3 docs say so directly, and our test reused one URL without trouble.
What the signature locks
We ran presigned PUT URLs against LocalStack 4.14.0, an S3 emulator, with signature checks switched on. Anything inside the signature could not be changed by the client.
| What the client tried | Result | Why |
|---|---|---|
| Upload with the signed Content-Type | 200 OK | Matches what was signed |
| Upload with a different Content-Type | 403 SignatureDoesNotMatch | Content-Type was part of the signature |
| Change the key in the URL to another user's folder | 403 SignatureDoesNotMatch | The key is always signed |
| Use the URL after it expired | 403 Request has expired | Expiry is checked when the request starts |
| Upload 3 MiB to a URL with no size signed | 200 OK, 3 MiB stored | Nothing signed limits the size, up to the 5 GB single PUT cap |
| Upload 2,000 bytes when 1,000 was signed as Content-Length | 403 SignatureDoesNotMatch | Content-Length was part of the signature |
| Upload as text/html when no type was signed | 200 OK, stored as text/html | The client chose the type |
The last row is the quiet danger. If you do not sign the type, a student can store a homework file as a web page, and anyone who opens it may get HTML instead of a PDF.
How long a presigned URL can live
On S3, X-Amz-Expires, the expiry setting inside the URL, runs from 1 to 604,800 seconds, which is 7 days, as of 26 October 2026. The SDKs allow up to 7 days, while the S3 console allows 1 minute to 12 hours.
The URL also dies early if the credentials that signed it expire. The S3 docs say a URL signed with role credentials dies when the role session ends, even if you asked for longer. On an EC2 virtual server, that is typically 6 hours.
For uploads, keep expiry short: 10 to 15 minutes for a small file, and about an hour per batch of part URLs. If a teacher's upload outlives that, your API can sign fresh part URLs for the parts still missing.
How do you limit file size and type on a direct upload?
Enforce limits in the signature, then verify the stored file. The client's own claims about name, size and type are hints, never proof.
Size: sign it with a POST policy
S3 also accepts browser-style form uploads with a POST policy. This is a signed JSON document of rules, and its content-length-range rule sets the smallest and largest allowed size in bytes.
Here is the homework start call in Python with boto3, the AWS software development kit (SDK) for Python. It signs a 10-minute form upload for one PDF of at most 10 MiB, under a key the server chose.
import uuid
import boto3
from botocore.config import Config
# Signs with your server's own AWS credentials, using Signature Version 4
s3 = boto3.client("s3", config=Config(signature_version="s3v4"))
BUCKET = "coaching-uploads"
MAX_PDF = 10 * 1024 * 1024 # 10 MiB
def create_homework_upload(user_id: str):
key = f"incoming/{user_id}/{uuid.uuid4()}.pdf" # server picks the name
post = s3.generate_presigned_post(
Bucket=BUCKET,
Key=key,
Fields={"Content-Type": "application/pdf"},
Conditions=[
{"Content-Type": "application/pdf"},
["content-length-range", 1, MAX_PDF],
],
ExpiresIn=600, # 10 minutes
)
return {"key": key, "url": post["url"], "fields": post["fields"]}We ran this code against LocalStack. A small PDF got 204 No Content, which means success. An 11 MiB file got 400 EntityTooLarge, and changing the Content-Type field to text/html got 403 AccessDenied.
For a presigned PUT instead of a form, you can sign Content-Length, as the table showed. That pins one exact size, so the client must send its real size in the start call.
Type: check the bytes, not the label
The Content-Type header and the file extension are both chosen by the client. The OWASP File Upload Cheat Sheet says plainly not to trust the Content-Type header, because it can be spoofed.
A better check reads the file's first few bytes, often called magic bytes. A PDF should begin with %PDF-, and most MP4 files have the letters ftyp at byte 4. A ranged GET fetches just those bytes.
SIGNATURES = {
"application/pdf": (0, b"%PDF-"),
"image/png": (0, b"\x89PNG\r\n\x1a\n"),
"video/mp4": (4, b"ftyp"),
}
def detected_type(key: str):
head = s3.get_object(Bucket=BUCKET, Key=key, Range="bytes=0-15")["Body"].read()
for mime, (offset, magic) in SIGNATURES.items():
if head[offset:offset + len(magic)] == magic:
return mime
return None # unknown: reject or send for manual reviewThis continues the homework snippet, reusing s3 and BUCKET. In our run it fetched only 16 bytes and got status 206, partial content. A file named renamed.pdf that really started with other bytes came back as unknown.
OWASP warns that magic bytes alone are easy to fake, so treat this as one layer. Pair it with an allow-list of extensions, server-chosen file names and the virus scan covered later.
How does S3 multipart upload work for large files?
A multipart upload sends a big file as numbered parts and joins them at the end. It has three steps: create the upload to get an upload ID, upload each part, then complete with every part's number and ETag.
The S3 docs recommend it for objects of 100 MB or more. A single PUT tops out at 5 GB, so bigger files must use parts anyway.
| Item | Limit | What it means for the lecture upload |
|---|---|---|
| Parts per upload | 10,000 | A 3 GiB lecture in 16 MiB parts uses 192 parts |
| Part numbers | 1 to 10,000 | You choose the numbers; they set the final order |
| Part size | 5 MiB to 5 GiB | The last part may be smaller than 5 MiB |
| Parts listed per ListParts call | 1,000 | Page through the list for bigger uploads |
| Single PUT without parts | Up to 5 GB | Fine for homework, not for long lectures |
The maximum object size is stated two ways. The limits table says 48.8 TiB, which matches 10,000 parts of 5 GiB (48.83 TiB). The December 2025 announcement and the upload page say 50 TB.
Picking a part size
The part count caps your file size. With the minimum 5 MiB parts, 10,000 parts only reach 48.83 GiB. A 100 GiB file needs parts of at least 10.24 MiB.
The boto3 upload helper starts at 8 MiB parts and, in s3transfer 0.14.0, doubles the size until the count fits. In our run, a 100 GiB file got 16 MiB parts and a 1,000 GiB file got 128 MiB parts.
For the coaching app, 16 MiB parts are a fair middle. A failed part costs at most 16 MiB of resending, not 2.7 GiB, and parts can go up two or three at a time on a good connection.
What the parts rules look like when you break them
Pause and predict before reading on. If a phone sends part 3 first, then 1, then 2, will the final video be scrambled?
No. The docs say parts can be uploaded independently and in any order, and S3 joins them in ascending part number. Our LocalStack run sent parts 3, 1, 2 and got a correct 10,486,760-byte file.
Then we broke each rule on purpose in the same LocalStack run. The error names match the ones the S3 API reference lists.
- Re-uploading a part number overwrites the earlier part. New bytes give a new ETag, so keep the ETag from the latest upload.
- Completing with parts listed out of order failed with 400 InvalidPartOrder.
- Completing with an old ETag for a part failed with 400 InvalidPart.
- A 1 MiB part was accepted when uploaded, but completing failed with 400 EntityTooSmall because it was not the last part. The rule is enforced at the end, not at the start.
- Part number 10,001 and part number 0 were both rejected with 400 InvalidArgument.
What just happened? The rules live at the complete step. A client can upload broken pieces for an hour and only learn at the end. So set a valid part size in your start call, and check what arrived before you complete.
Uploading parts from a browser or phone
Your API can presign one URL per part, so the parts go straight to storage too. This sketch continues the homework snippet, reusing s3, BUCKET and uuid.
PART_SIZE = 16 * 1024 * 1024 # 16 MiB
MAX_LECTURE = 4 * 1024**3 # 4 GiB
def start_lecture_upload(user_id: str, size: int):
if size not in range(1, MAX_LECTURE + 1):
raise ValueError("lecture must be between 1 byte and 4 GiB")
key = f"incoming/{user_id}/{uuid.uuid4()}.mp4"
mpu = s3.create_multipart_upload(Bucket=BUCKET, Key=key, ContentType="video/mp4")
count = -(-size // PART_SIZE) # ceiling division
urls = [
s3.generate_presigned_url(
"upload_part",
Params={"Bucket": BUCKET, "Key": key,
"UploadId": mpu["UploadId"], "PartNumber": n},
ExpiresIn=3600,
)
for n in range(1, count + 1)
]
return {"key": key, "uploadId": mpu["UploadId"],
"partSize": PART_SIZE, "partUrls": urls}
def complete_lecture_upload(key: str, upload_id: str, size: int):
pages = s3.get_paginator("list_parts").paginate(
Bucket=BUCKET, Key=key, UploadId=upload_id)
parts = [p for page in pages for p in page.get("Parts", [])]
if sum(p["Size"] for p in parts) != size:
raise ValueError("uploaded bytes do not match the declared size")
done = s3.complete_multipart_upload(
Bucket=BUCKET, Key=key, UploadId=upload_id,
MultipartUpload={"Parts": [
{"PartNumber": p["PartNumber"], "ETag": p["ETag"]} for p in parts]},
)
return done["ETag"]The complete call asks storage which parts really arrived, with ListParts, and refuses if the bytes do not add up to the declared size. In our run, a client that declared 20 MiB but sent 17 MiB was refused.
The check cannot be dodged by swapping a part afterwards. The complete call names each part's ETag, and a part replaced after the check fails with InvalidPart, as in our earlier run.
A browser needs one more setting. CORS is the browser rule for which other sites a page may call and which reply headers it may read. ETag is not on the Fetch standard's default list.
So add ETag to the ExposeHeaders list in your bucket's CORS settings. Without it, your web page uploads every part but cannot read the ETags it needs to finish.
The final multipart ETag in our run looked like a hash of the part hashes followed by -3, for three parts. The S3 docs only call it a checksum of checksums. Undocumented as of 26 October 2026, so never parse it.
How do you finish, cancel and clean up uploads safely?
Finish with one complete call that you can safely retry, cancel with abort, and set a lifecycle rule to clean up the uploads everyone forgot. Until one of those happens, S3 stores and bills the parts.
Life of one upload record
Drag to exploreNotice that every path ends in one of three resting states. An upload that never reaches complete or abort is caught by the expiry sweep, so parts never sit around for ever.
Diagram connections:
- Created to Uploading (client starts)
- Uploading to Complete call arrives? (client stops sending)
- Complete call arrives? to Scanning (Yes, sizes match)
- Complete call arrives? to Aborted or expired (Abort, or no reply in days)
- Scanning to Ready (clean)
- Scanning to Rejected (bad file)
- Created: Row saved, URLs signed
- Uploading: Parts going straight to storage
- Complete call arrives?: Or abort, or silence
- Scanning: File joined, waiting in quarantine
- Ready: Moved to clean/, visible to students
- Rejected: Wrong type, wrong size or infected
- Aborted or expired: Parts deleted, row closed
Completing without surprises
The S3 docs warn that completing can take several minutes. They also say a 200 OK reply can contain an error, so code that calls the API directly must read the response body. The botocore 1.40.76 source turns such a reply into a 500 and retries it.
Retries need care. On LocalStack, a second complete after a successful one returned 404 NoSuchUpload, because the upload ID no longer existed. Before treating that as failure, send a HEAD request, which fetches only the object's details, to see if the file exists.
Two optional guards help. Sending the expected total size as MpuObjectSize makes S3 reject a mismatch with 400 InvalidRequest. Sending If-None-Match with an asterisk refuses to overwrite a key that already exists, with 412.
Aborting and the forgotten uploads
Abort frees the parts. It returned 204 No Content in our run, and after that every call with the upload ID returned NoSuchUpload.
One catch comes from the S3 docs. Part uploads still in flight might succeed after an abort, so you may need to abort more than once and confirm with ListParts that no parts remain.
The bigger risk is silence. A teacher closes the app at 60 percent and never returns. In our emulator test, the parts did not show up in a normal object listing. The S3 docs say they are billed until the upload is completed or aborted.
The fix is a bucket lifecycle rule with the AbortIncompleteMultipartUpload action. Any upload not completed within a set number of days becomes eligible for abort, and S3 then deletes its parts. Finished objects are never touched.
{
"Rules": [
{
"ID": "abort-stale-uploads",
"Status": "Enabled",
"Filter": {
"Prefix": "incoming/"
},
"AbortIncompleteMultipartUpload": {
"DaysAfterInitiation": 7
}
}
]
}Run a matching sweep in your own database too. Mark rows still in the uploading state after the same number of days as expired, so your records agree with storage.
What makes an upload resumable, and how does tus 1.0 work?
An upload is resumable when the client can ask how many bytes the server already has and send only the rest. The tus protocol is an open standard that defines exactly those messages over plain HTTP.
S3 multipart is already resumable at part level. After a crash, the client calls ListParts, sees which part numbers exist and sends the missing ones. Tus goes finer, down to the byte. It runs through your own tus server, which can save to disk or to object storage.
Tus 1.0.0, dated 25 March 2016, is the current version as of 26 October 2026. It uses three core requests plus a creation extension.
Create the upload
The client sends POST with Upload-Length, the total size in bytes. The server answers 201 Created with a Location header, the upload's own URL.
Send bytes with PATCH
The client sends PATCH with Upload-Offset, where these bytes start, and the type application/offset+octet-stream. The server answers 204 with the new offset.
After a drop, ask with HEAD
The client sends HEAD to the upload URL. The server answers with Upload-Offset, the bytes it really has, and Cache-Control no-store so no cache serves an old answer.
Continue from that offset
The client sends PATCH from the returned offset. If its offset is wrong, the server answers 409 Conflict and changes nothing.
Cancel if needed
With the termination extension, DELETE on the upload URL answers 204 No Content, and the server frees the stored bytes.
A tus upload that survives a dropped connection
Drag to exploreNotice that the client never guesses where to continue. It asks with HEAD and trusts the server's offset, so a stale client gets a 409 instead of corrupting the file.
Diagram connections:
- POST, Upload-Length 20 to 201 with Location (create)
- 201 with Location to PATCH 8 bytes at offset 0 (start sending)
- PATCH 8 bytes at offset 0 to HEAD the upload URL (connection lost)
- HEAD the upload URL to Upload-Offset 8 (ask)
- Upload-Offset 8 to PATCH 12 bytes at 8 (resume from 8)
- POST, Upload-Length 20: Client → tus server: create
- 201 with Location: Server → Client: your upload URL
- PATCH 8 bytes at offset 0: Client → Server: then the line drops
- HEAD the upload URL: Client → Server: how much do you have?
- Upload-Offset 8: Server → Client: 8 of 20 bytes stored
- PATCH 12 bytes at 8: Client → Server: offset becomes 20
We ran this exact exchange against @tus/server 2.4.6, the project's Node.js server, using curl. The stored file came out byte for byte correct, and the server answered 413 to a creation larger than its Tus-Max-Size.
One small gap showed up. A PATCH with the wrong content type got 400 Bad Request, while the spec says a server should answer 415. So do not code a client to expect exactly 415 for that mistake.
Other tus extensions cover checksums per chunk, with status 460 when a chunk does not match, expiry of unfinished uploads, and joining parallel uploads. A server lists the ones it supports in the Tus-Extension header of an OPTIONS reply.
| Method | Best for | Resume after a drop | Where limits are enforced |
|---|---|---|---|
| Presigned PUT | Files up to a few hundred MB | No, start again | Signed headers such as Content-Length |
| Presigned POST form | Browser forms like homework PDFs | No, start again | The POST policy, such as content-length-range |
| S3 multipart with part URLs | Big files going straight to S3 | Yes, per part, using ListParts | Your start and complete calls, plus S3 part rules |
| tus through your server | Any storage, byte-level resume | Yes, per byte, using HEAD | Your tus server, such as Tus-Max-Size |
What is the IETF resumable uploads draft and when can you use it?
It is a proposed HTTP standard for resumable uploads, grown out of tus and worked on by the IETF HTTP working group. As of 26 October 2026 it is still an Internet-Draft, a working document, not an RFC, the IETF's published standard form.
The ideas will feel familiar. An Upload-Complete header says whether a request carries the last byte, Upload-Offset reports progress, HEAD asks for the offset, PATCH appends, and DELETE cancels.
One part is new. An interim 104 response, an early provisional reply named Upload Resumption Supported, can tell the client its upload URL before the first request finishes. Limits move into one Upload-Limit header, with keys such as max-size and max-age.
The 104 status number is only a suggested value in the draft. Treat everything here as what is coming, not what to ship.
How do you hand uploads off to a virus scanner?
Keep every new file in quarantine and let storage raise an event when it lands. A worker scans it and only then moves it to where users can reach it. Your API serves files from the clean area only.
On S3, an event such as s3:ObjectCreated:CompleteMultipartUpload or s3:ObjectCreated:Post can go to a queue, a topic, a function or an event bus. The worker pulls the event and scans the object.
The S3 docs say these events arrive at least once, not always in order, and rarely twice. So the scan worker must be idempotent: scanning the same file twice should give the same result and no double side effects.
Land in quarantine
Signed keys always start with incoming/. Bucket rules stop anyone outside your backend from reading that prefix.
Raise an event
Storage sends an object-created event to a queue. The worker records the key in the uploads table, so duplicates are spotted.
Check, then scan
Check the size and magic bytes first, then run an antivirus engine such as ClamAV, an open-source scanner. OWASP also suggests a sandbox or content disarm for PDF and Office files.
Promote or reject
A clean file is copied to clean/ and the quarantine copy is deleted. A bad file is tagged and kept for review, and the upload row moves to rejected.
Tell the user
The app polls the upload row, or your backend calls the app's server back with a webhook. Until then, show the file as processing, not as ready.
We tested the promote step on LocalStack: tag the object scan=clean, copy it to clean/ with its tags, then delete the original. The copy kept the tag and the quarantine key was gone.
Archives need extra care. OWASP lists zip bombs, small files that expand to huge ones, and says size limits should be checked after decompression. Many apps simply refuse archives.
To check your pipeline end to end, upload the EICAR test file, a harmless file that antivirus tools are built to flag. It should end in the rejected state, never in clean/.
How do you explain file upload API design in a system design interview?
Lead with the split: your API owns permission and records, storage owns bytes. Then walk one large file through its life, as below.
State the needs
Files from KB to many GB, flaky mobile networks, untrusted content and no app servers in the data path.
Start call
The API checks the user and the declared size and type, picks the key and returns presigned URLs with short expiry.
Direct upload
Small files go up in one signed PUT or POST. Big files go in parts of about 16 MiB, in parallel, resumable per part.
Complete call
The API lists the parts, checks the total size, completes the upload and makes retries safe.
Quarantine and scan
Storage events drive an idempotent scan worker. Only clean files move to the readable area.
Cleanup and limits
A lifecycle rule aborts stale uploads after 7 days. Name the S3 limits: 10,000 parts, 5 MiB to 5 GiB each, URLs up to 7 days.
Questions people ask
- Should file uploads go through my API server or straight to storage?
- Straight to storage, in most cases. Your API signs a presigned URL or form, and the client sends the bytes directly to object storage. Your servers then handle only small start and complete calls. Route bytes through your own server only when you must change them on the way in, or when your storage cannot check signatures itself.
- How long should a presigned upload URL last?
- As short as the upload needs, usually minutes. S3 allows 1 second to 7 days as of 26 October 2026, but a leaked URL works for anyone until it expires. Ten to fifteen minutes suits a small file. For a long multipart upload, sign part URLs in batches and sign fresh ones when needed.
- What is the minimum part size for an S3 multipart upload?
- 5 MiB for every part except the last, which can be any size. S3 reports a too-small middle part with EntityTooSmall when you complete the upload. Our emulator run accepted the small part itself without complaint. Parts can be up to 5 GiB, and one upload can have up to 10,000 parts.
- What happens to an S3 multipart upload that is never completed?
- Its parts stay in the bucket and are billed until someone completes or aborts the upload. They do not appear in a normal object listing, so they are easy to forget. Add a lifecycle rule with AbortIncompleteMultipartUpload, for example after 7 days, and expire the matching rows in your own uploads table.
- Is the tus protocol the same as the IETF resumable uploads draft?
- No, but they are related. Tus 1.0.0 is a stable open protocol from 2016 with working servers and clients today. The IETF draft grew out of tus and uses similar ideas, such as offsets, HEAD and PATCH, but with new headers. As of 26 October 2026 it is draft 14, not yet an RFC.
- How do I stop users uploading the wrong file type?
- Use several layers, because each one alone is weak. Allow only the extensions you need, sign the Content-Type you expect, and choose the file name on the server. After upload, read the first bytes to confirm the real format, then scan the file before anyone else can open it.
- Do I need a virus scan if files are stored in S3 and never run on my servers?
- Yes, if other people will download them. Storage keeps the file safe from your servers, but a student who downloads an infected file is still harmed. Keep new files in a quarantine prefix, scan them in a worker triggered by storage events, and serve only files that passed.
Sources and further reading
- Amazon S3 User Guide: Multipart upload limits
- Amazon S3 User Guide: Uploading and copying objects using multipart upload
- Amazon S3 User Guide: Uploading objects (5 GB single PUT, 50 TB multipart)
- AWS What's New, 2 December 2025: Amazon S3 increases the maximum object size to 50 TB
- Amazon S3 User Guide: Download and upload objects with presigned URLs
- Amazon S3 API Reference: Authenticating requests with query parameters (X-Amz-Expires)
- Amazon S3 API Reference: POST policy (content-length-range)
- Amazon S3 API Reference: CompleteMultipartUpload
- Amazon S3 API Reference: AbortMultipartUpload
- Amazon S3 User Guide: Lifecycle rule to abort incomplete multipart uploads
- Amazon S3 User Guide: Event notification types and destinations
- Amazon S3 User Guide: Elements of a CORS configuration (ExposeHeaders)
- WHATWG Fetch Standard: CORS-safelisted response-header names
- tus resumable upload protocol 1.0.0
- IETF datatracker: draft-ietf-httpbis-resumable-upload (draft 14, 18 October 2026)
- OWASP File Upload Cheat Sheet
- ClamAV documentation
- EICAR: anti-malware test file