July 12, 2026
Building a File Upload Scanning Pipeline for Laravel and S3
Direct uploads solve throughput. They do nothing to stop malicious files.

By Hafiq Iqmal
9 min read
The most dangerous sentence in a file upload design review is usually this one: "It goes straight to S3, so the app server never touches it."
That sounds efficient because it is efficient. It also hides the actual security question. You are not trying to protect the upload endpoint from large files alone. You are trying to stop untrusted content from becoming retrievable, shareable, cached, or accidentally executed anywhere in your stack.
A lot of Laravel apps get the first half right. They mint a presigned upload URL, the browser pushes bytes to object storage, the API stores the key, and everyone goes home happy. Then the product team adds previews, exports, admin downloads, webhook fanout, or CDN delivery. Now the file is moving through systems that assume somebody else already checked it. Nobody did.
The useful mental model is simpler than most architecture diagrams. Upload is ingestion. Download is release. Your pipeline is safe only when unscanned files can enter the system without ever becoming addressable to another user, another service, or your own support staff by accident.
The naive pipeline is fast and unsafe
The common version looks like this:
- The client asks Laravel for an upload URL.
- Laravel returns a short-lived S3 URL.
- The client uploads the file directly.
- Laravel stores the object key in MySQL.
- The app later renders a download link or a public asset URL.
It is attractive for good reasons:
- Your PHP workers stop buffering large request bodies.
- Better burst handling at the storage layer.
- Retries stay closer to the bytes.
- And yes, the architecture looks modern enough to survive a sprint demo.
Security-wise, though, this pipeline usually makes four bad assumptions.
First, it trusts metadata the client sent. OWASP is explicit here: content type is user supplied and easy to spoof, file signatures help but are not enough on their own, and you need multiple controls rather than one magic validator. If your logic says "the browser claimed image/png, ship it", you do not have a security control. You have paperwork.
Second, it treats object existence as proof of safety. That is backwards. The only thing S3 tells you is that bytes were written successfully. It says nothing about whether the payload is a macro-laced document, a polyglot file, or something your downstream image library will parse into a bad afternoon.
Third, it makes retrieval too easy. Public file retrieval creates its own threat surface. OWASP calls out the obvious version, which is hosting dangerous content for other people, but the quieter failure is internal. An admin panel, an audit export, or a support tool becomes the first place unsafe content gets opened because the main product path happened to block it.
Fourth, teams mix upload storage and release storage. Same bucket, same prefix, same serving path, same lifecycle. That is convenient. It also means a single mistake in authorization logic, CDN config, object ACLs, or link generation can expose material that was supposed to stay untrusted.
That is why "direct to S3" is not the design. It is one transport choice inside the design.
What a production-safe pipeline actually needs
A safe upload system needs seven separate moves:
- Authenticate the uploader and validate the intended file class before granting any upload permission.
- Issue a short-lived upload URL for a quarantine location, not a release location.
- Record a pending scan state in your database before the client uploads.
- Trigger an asynchronous scan flow when the object lands.
- Run layered validation on the server side: extension allowlist, MIME sniffing, signature checks, size limits, and malware scanning.
- Persist the verdict somewhere cheap to read, usually in your database and optionally on the object as tags.
- Generate download URLs only for objects that already have a clean verdict.
That sequence matters. If step 7 can happen before step 5 finishes, you do not have quarantine. You have optimism.
The non-obvious part is step 7. Most teams obsess over how uploads enter the system. Mature systems obsess over how files leave it. Release is the gate.
Start with a narrow upload grant
Laravel 12 gives you a useful primitive here: temporaryUploadUrl. If your upload disk is backed by S3, Laravel can return the signed URL and the headers the client must send.
<?php
use App\Models\PendingUpload;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class CreateUploadUrlController
{
public function __invoke(Request $request): array
{
$request->validate([
'original_name' => ['required', 'string', 'max:255'],
'declared_type' => ['required', 'string', 'max:100'],
]);
$upload = PendingUpload::create([
'id' => (string) Str::uuid(),
'owner_id' => $request->user()->id,
'original_name' => $request->string('original_name'),
'declared_type' => $request->string('declared_type'),
'status' => 'pending_upload',
]);
$path = "quarantine/{$request->user()->id}/{$upload->id}";
['url' => $url, 'headers' => $headers] = Storage::temporaryUploadUrl(
$path,
now()->addMinutes(5)
);
$upload->forceFill([
'storage_disk' => 's3',
'storage_path' => $path,
'status' => 'pending_scan',
])->save();
return [
'upload_id' => $upload->id,
'url' => $url,
'headers' => $headers,
];
}
}<?php
use App\Models\PendingUpload;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class CreateUploadUrlController
{
public function __invoke(Request $request): array
{
$request->validate([
'original_name' => ['required', 'string', 'max:255'],
'declared_type' => ['required', 'string', 'max:100'],
]);
$upload = PendingUpload::create([
'id' => (string) Str::uuid(),
'owner_id' => $request->user()->id,
'original_name' => $request->string('original_name'),
'declared_type' => $request->string('declared_type'),
'status' => 'pending_upload',
]);
$path = "quarantine/{$request->user()->id}/{$upload->id}";
['url' => $url, 'headers' => $headers] = Storage::temporaryUploadUrl(
$path,
now()->addMinutes(5)
);
$upload->forceFill([
'storage_disk' => 's3',
'storage_path' => $path,
'status' => 'pending_scan',
])->save();
return [
'upload_id' => $upload->id,
'url' => $url,
'headers' => $headers,
];
}
}Two details matter more than the signing call itself.
Use a random internal key, not the original filename. Filenames are messy, user controlled, and surprisingly good at finding parser edge cases. Keep the user-facing name in metadata if you need it later.
Also, write the pending row before the upload starts. That gives you something durable to reconcile against when uploads partially succeed, clients retry, or storage events arrive more than once. Which they can.
The browser side stays tiny:
const response = await fetch("/api/uploads/url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
original_name: file.name,
declared_type: file.type,
}),
});
const { url, headers } = await response.json();
await fetch(url, {
method: "PUT",
headers,
body: file,
});const response = await fetch("/api/uploads/url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
original_name: file.name,
declared_type: file.type,
}),
});
const { url, headers } = await response.json();
await fetch(url, {
method: "PUT",
headers,
body: file,
});That is enough for ingestion. It is not enough for trust.
Quarantine first, always
Your storage layout should make the safe path boring and the unsafe path annoying.
The simplest version is one bucket with separate prefixes:
- quarantine/ for newly uploaded objects
- clean/ for files that passed checks
- rejected/ for objects you want retained for analyst review or short-term forensics
The stronger version is two buckets:
- an incoming bucket that only the uploader and the scanner can touch
- a release bucket that only clean-object workflows can write to
Both can work. The wrong version is one shared location where upload completion also makes the file eligible for serving.
AWS S3 event notifications help trigger the next step, but they come with two design constraints that people forget fast. They are delivered at least once, and they can arrive seconds later or even after longer delay. So the scan worker must be idempotent. AWS also warns that writing back to the same bucket can create an execution loop if your notification target stores another object that matches the trigger. That is not a theoretical footnote. It is how you create a surprise bill and a broken queue in the same hour.
You can solve this in a few ways:
- Use separate buckets for incoming and clean files.
- If you stay in one bucket, trigger only on the quarantine/ prefix.
- Tag or update state without extra writes unless promotion is required.
I usually like one incoming prefix and one clean prefix first. It is cheaper to operate, and with tight prefix filters it stays understandable. Once policy separation becomes more important than simplicity, move to two buckets.
The scan worker should do more than antivirus
Malware scanning is necessary for many workloads. It is not the whole control set.
OWASP's cheat sheet is blunt about this: use defense in depth. That means your queued scan job should evaluate the file from several angles before it ever gets promoted:
- Start with an allowed extension set that matches the product, not a generic "documents" bucket.
- Verify MIME on the server side.
- Check file signatures or magic bytes.
- Enforce size and decompression limits.
- Randomized filenames.
- Malware scanning.
- If your product lives on Office files and PDFs all day, consider content disarm and reconstruct as well.
That layered approach matters because each signal catches a different class of nonsense.
Content-Type is useful for mistakes, not trust. It tells you when a normal user picked the wrong file. It does not tell you whether an attacker crafted the right lie.
File signatures are useful for fast mismatch checks. They also fail on container formats, partial files, and intentionally malformed inputs. Good. Keep them anyway. Just do not pretend they settle the question.
Antivirus gives you a signature-based safety net for known threats and suspicious structures. ClamAV is a common choice because it is open source, has a daemon mode, handles archive scanning, and is easy to wrap in a job worker or isolated scanning service. If you would rather not manage signatures yourself, a managed scanner can sit in the same place in the architecture.
The queued job usually looks more like orchestration than clever code:
on ObjectCreated(quarantine_key):
upload = find database row by object key
if upload.status is clean or rejected:
stop
stream object from quarantine storage
detect mime and signature
enforce extension and size policy
run malware scanner
if any check fails:
tag object scan-status=rejected
move to rejected prefix or delete
mark database row rejected
emit security event
stop
tag object scan-status=clean
copy or move object to clean prefix
mark database row cleanon ObjectCreated(quarantine_key):
upload = find database row by object key
if upload.status is clean or rejected:
stop
stream object from quarantine storage
detect mime and signature
enforce extension and size policy
run malware scanner
if any check fails:
tag object scan-status=rejected
move to rejected prefix or delete
mark database row rejected
emit security event
stop
tag object scan-status=clean
copy or move object to clean prefix
mark database row cleanNotice what is missing: raw file bytes in the queue payload. Laravel's queue docs warn against pushing binary blobs directly into jobs, so pass identifiers and re-fetch what you need inside the worker, where retries and scan logs belong. Smaller payloads, fewer surprises.
Tag the object, but trust your database first
S3 object tags are handy because they travel with the object and are cheap to inspect in adjacent workflows. You can attach up to 10 tags to an object, which is plenty for something like:
- scan-status=clean
- scan-engine=clamav
- scan-version=2026–05–27
- owner-id=12345
That makes it easier for batch cleanup jobs, incident tooling, and storage-side policy checks to understand object state without joining back to MySQL every time.
Still, I would not make tags the sole source of truth.
Use the database row as your authority for product logic because that is where ownership, retention policy, business context, and audit trails belong. Use tags as fast metadata for storage-oriented workflows. The split is practical. It keeps your API logic readable while still giving operations people a way to inspect state at the object layer.
And if you are on DigitalOcean Spaces or another S3-compatible store, Laravel's S3 disk abstraction keeps the application code mostly the same. That is convenient, especially for small teams. You still need to confirm which eventing and tagging features your provider mirrors closely before you copy the AWS playbook line by line.
Release is where trust becomes real
Here is the rule I wish more teams wrote into code instead of architecture notes:
Never generate a download URL for an object whose scan status is not clean.
Not "clean or pending if the user uploaded it themselves." Not "clean unless an admin toggles a bypass." Not "clean, but the thumbnail service can fetch quarantine objects internally."
If another system can read the bytes before the verdict lands, that system is now part of your malware exposure path.
A Laravel download endpoint can keep this brutally simple:
<?php
use App\Models\PendingUpload;
use Illuminate\Support\Facades\Storage;
class DownloadUploadController
{
public function __invoke(PendingUpload $upload)
{
abort_unless($upload->status === 'clean', 404);
abort_unless($upload->owner_id === auth()->id(), 403);
return redirect(
Storage::disk('s3')->temporaryUrl(
$upload->released_path,
now()->addMinutes(5)
)
);
}
}<?php
use App\Models\PendingUpload;
use Illuminate\Support\Facades\Storage;
class DownloadUploadController
{
public function __invoke(PendingUpload $upload)
{
abort_unless($upload->status === 'clean', 404);
abort_unless($upload->owner_id === auth()->id(), 403);
return redirect(
Storage::disk('s3')->temporaryUrl(
$upload->released_path,
now()->addMinutes(5)
)
);
}
}That endpoint is doing the real security work. The upload route only staged the problem.
This is also where product decisions show up. If scans are asynchronous, the UI needs a pending state. Users may upload a document and wait a few seconds before it becomes available. That is fine. Security boundaries that nobody can explain in the interface tend to get bypassed later.
Trade-offs that actually matter
Most debates about file uploads focus on tooling. The sharper decisions are operational.
Single bucket with prefixes
- Cheaper and easier to reason about on day one.
- Requires disciplined event filters and cleaner IAM boundaries.
- Easier for a rushed engineer to punch a hole through later.
Two buckets
- Stronger separation between untrusted and releasable content.
- Cleaner policy story for auditors and incident response.
- More copy operations, more moving parts.
Inline scanning during the request
- The verdict is immediate.
- Large files punish your API latency.
- Retries get ugly fast.
Asynchronous scanning
- Scales far better for real uploads.
- Fits Laravel queues naturally.
- Forces you to model pending and rejected states honestly.
Delete on detect
- Simple and cheap.
- Bad for investigation when you need to inspect false positives or abuse patterns.
Quarantine on detect
- Better for security operations.
- Needs retention rules so your rejected bucket does not become a museum of bad decisions.
My default for a small team is asynchronous scanning, one quarantine prefix, one clean prefix, and explicit release gating through application logic. It is not the fanciest design. It is the one you can keep correct.
Test the pipeline like an attacker, not like a demo
A secure design that never gets exercised will rot quietly.
That is where the EICAR test file helps. It is safe to pass around, it is not real malware, and antivirus products are expected to detect it as if it were infected. That makes it perfect for pipeline health checks. Upload it to quarantine, verify that your scanner flags it, confirm the object never becomes downloadable, and make sure the rejection path writes the audit event you expect.
Then go one step further.
Test the failure modes:
- What happens if the upload succeeds but the database write fails?
- What happens if S3 delivers the same event twice?
- What happens if the scanner times out halfway through a large archive?
- What happens if the object is deleted before the worker reads it?
- What happens if a clean object is later reprocessed by mistake?
Those are not edge cases. They are the shape of production.
The strongest upload pipelines are not the ones with the most scanners. They are the ones where every state transition is explicit, boring and hard to bypass. You want four plain states: pending, clean, rejected, expired. Nothing mystical. Just a system that knows when a file is untrusted and refuses to forget.
Fast upload paths are easy. Safe release paths are the engineering.