Contents
User files in a web app are best stored in S3-compatible object storage rather than on the server's disk: it survives server changes, scales effortlessly and costs less than disks. Public files are served through a CDN, private ones via temporary signed URLs, and uploads are checked for type, size and content, because user files are a major attack vector.
Here is how to handle uploading, storing and serving files reliably and securely.
Where should user files be stored?
Store user files in object storage and keep only metadata in the database: file path, owner, type and size. A server disk works for a single server and a prototype but breaks when you scale: a second server cannot see the first one's files, and they are easy to lose during a migration.
| Option | Pros | Cons | When it fits |
|---|---|---|---|
| Server disk | Simple, no external services | Does not scale, risk of loss if the server fails | Prototype, single server |
| Object storage (S3-compatible) | Reliable, scalable, cheap | Access must be configured | Most products |
| Database | Everything in one place | Bloats the database, slow backups | Almost never for files |
Most cloud providers offer storage with an S3-compatible API, so application code does not depend on a specific cloud.
How should uploads be accepted?
Accept uploads with size, type and content checks, store them under a random name and never execute them. The file name and extension a user sends deserve no trust: a malicious script is easily named photo.jpg.
public function store(Request $request): JsonResponse
{
$request->validate([
// The type is checked from the file contents, not the extension
'file' => ['required', 'file', 'max:10240', 'mimes:jpg,png,webp,pdf'],
]);
// Random name in a private directory; the original name goes only into metadata
$path = $request->file('file')->store("uploads/{$request->user()->id}", 's3');
$upload = $request->user()->uploads()->create([
'path' => $path,
'original_name' => $request->file('file')->getClientOriginalName(),
'size' => $request->file('file')->getSize(),
]);
return response()->json(['id' => $upload->id], 201);
}
Rules for safe uploads
- Limit size and the list of allowed types.
- Check the type from the content, not the extension.
- Use random file names, never user-supplied paths.
- Keep storage separate from application code so files cannot be executed.
- Re-encode images, which also strips metadata such as geolocation.
- Scan documents from unknown users with an antivirus.
File upload vulnerabilities are part of the general web application risk list — see OWASP Top 10 explained.
Photo: Unsplash
How do you serve public and private files?
Serve public files — catalogue images, avatars, files on public pages — through a CDN with long caching. Serve private ones — documents, invoices, files from user accounts — through temporary signed URLs that are issued after a permission check and expire within minutes.
// A link to a private document lives for 10 minutes and is issued only to the owner
Gate::authorize('view', $upload);
$url = Storage::disk('s3')->temporaryUrl($upload->path, now()->addMinutes(10));
What to keep in mind:
- private files never live in a public folder or public bucket;
- permissions are checked when the link is issued, as for any other data;
- images benefit from versions in several sizes, generated on upload or on the fly;
- public files get content-hashed names so the CDN cache refreshes automatically — see caching in web applications.
How do you upload large files?
Upload large files — video, archives, big documents — straight to storage, bypassing the application server: the server gives the browser a signed upload URL and the browser sends the file to storage in parts. The server does not hold a connection open for minutes or hit memory limits.
The upload flow:
- the client asks the server for permission to upload, stating type and size;
- the server checks permissions and limits and issues a signed URL;
- the client uploads to storage, in resumable parts if needed;
- storage or the client notifies the server on completion; the server checks the file and saves metadata;
- processing — previews, conversion, virus scanning — runs as a background job.
What does storage cost and how can you cut it?
Object storage is inexpensive, but costs grow through outbound traffic, request counts and files nobody deletes. Cost control rests on file lifecycle rules and a CDN.
What helps:
- Delete temporary and unconfirmed uploads automatically.
- Move old, rarely used files to cheaper storage classes.
- Serve public files through a CDN to cut storage egress.
- Compress images and store them in modern formats.
In short
- Store files in S3-compatible object storage and keep only metadata in the database.
- On upload, check size and type from the content and save under a random name.
- Serve public files through a CDN and private ones via temporary signed URLs after a permission check.
- Upload large files directly to storage via a signed URL and process them in the background.
- Automate deletion of unneeded files and move old ones to cheaper storage classes.
FAQ
Can files be stored in the database?
Technically yes, but it bloats the database and slows backups and replication. It is almost always better to keep files in object storage and store a reference and metadata in the database.
Does object storage need backups?
Storage services protect well against hardware failure but not against accidental deletion. Enable object versioning or cross-region replication for important files.
How do we give time-limited access to files?
With signed URLs: after checking permissions, the server issues a link valid for a few minutes. Once it expires, nothing can be downloaded even if the link was forwarded.



