Skip to content
Backend & Architecture•13 min read•Published September 16, 2026

Secure Multi-Tenant File Storage in Laravel SaaS

A public S3 bucket and a predictable file path is how one tenant's uploads end up in another tenant's browser tab.

Aqib Javaid
Aqib Javaid
Senior Full-Stack Engineer
Dark tech blog cover showing tenant-isolated S3 folders, a signed URL padlock icon, and a Laravel file upload pipeline

Introduction: The Upload Button Is the Easy Part#

Every Laravel file upload feature starts the same way: a form input, a store() call, a row in a files table pointing at a path. It works in the demo, it works in staging with one test account, and then it ships to a multi-tenant SaaS product where hundreds of businesses are uploading their own documents, photos, and media into the same application — and every shortcut taken in that first version turns into either a support ticket or a security incident. A file path that's just uploads/{filename} means two tenants uploading a file called invoice.pdf on the same day silently overwrite each other. A public S3 bucket with "unguessable" UUID filenames means the file is exactly one leaked link away from being public forever, because nothing about a UUID enforces who's allowed to ask for it a second time. A thumbnail generated synchronously inside the upload request means a customer's page hangs for three seconds every time someone attaches a photo. None of this shows up when one person is testing. All of it shows up the first week the product has real tenants uploading real files at the same time.

I've built this pattern for products where getting it wrong has real consequences: FileManager, a cloud-based file management system I built on Laravel, Livewire, Alpine.js, and Amazon S3 — secure storage with role-based access controls, drag-and-drop uploads, bulk file actions, and preview support, for customers who use it to store business-critical documents; SignageFlow, the digital signage SaaS where every customer's screen fleet pulls image and video assets from the platform continuously, so the storage layer isn't just "hold a file," it's "serve high-bandwidth media to remote devices, fast, without leaking one customer's creative assets to another customer's screens"; and SafetySpace, the AI-powered safety platform I run as CTO, where incident reports carry photo and document evidence that has to be retained, auditable, and restricted to the exact people authorized to see it — sometimes for regulatory reasons that outlast the incident itself by years.

This is a breakdown of how to architect Laravel file storage that stays correct once it's carrying real tenant data: isolated at the storage layer, access-controlled on every read, processed off the request thread, and built so a signed link expiring is a feature, not a bug someone has to explain to a customer.

Architecture: What "Secure Multi-Tenant" Actually Requires#

Storage security for a multi-tenant app is four separate guarantees, and getting three of them right while skipping the fourth just moves the failure mode somewhere less obvious.

1. Tenant isolation lives in the storage path, not just the database row#

The mistake I inherit most often is a files table with a tenant_id column that every query filters on correctly, sitting next to a storage path that has no tenant information in it at all — uploads/a1b2c3d4.pdf. The database relationship is airtight; the actual bytes on S3 are not. The moment any code path generates a storage URL from something other than that fully-scoped query — a cached value, a queued job that lost its tenant context, an admin tool built in a hurry — there's nothing left in the physical path to stop it from resolving to the wrong tenant's file.

The fix is to make the tenant boundary structural in the path itself, not just in a column you have to remember to filter on:

php
class TenantFileService
{
    public function __construct(private TenantContext $tenantContext) {}

    public function pathFor(string $filename): string
    {
        $tenantId = $this->tenantContext->id();

        return sprintf(
            'tenants/%d/%s/%s',
            $tenantId,
            now()->format('Y/m'),
            Str::uuid() . '_' . Str::slug(pathinfo($filename, PATHINFO_FILENAME)) . '.' . pathinfo($filename, PATHINFO_EXTENSION)
        );
    }
}

tenants/{tenantId}/2026/09/uuid_original-name.pdf means a bug that skips the tenant_id database filter still can't accidentally serve tenant 42's file to tenant 7 — the path itself doesn't exist under the wrong tenant's prefix. This is the same discipline covered in my piece on engineering scalable SaaS architecture: isolation enforced structurally, everywhere a boundary matters, not remembered per query.

2. Every read goes through a scoped, time-limited signed URL — never a public bucket#

A public-read S3 bucket with unguessable filenames feels safe because nobody can enumerate the URLs. It isn't safe, because "unguessable" and "access-controlled" are different properties. A UUID filename that leaks once — pasted into a shared Slack channel, cached by a browser extension, logged by an analytics tool — is public forever, because nothing about the bucket configuration checks who's asking. Access control has to happen on every single read, not once at upload time.

php
class FileAccessController extends Controller
{
    public function show(Request $request, File $file)
    {
        $this->authorize('view', $file); // tenant scope + RBAC, both, every time

        $url = Storage::disk('s3')->temporaryUrl(
            $file->path,
            now()->addMinutes(5),
            ['ResponseContentDisposition' => 'inline; filename="' . $file->original_name . '"']
        );

        return response()->json(['url' => $url]);
    }
}
php
class FilePolicy
{
    public function view(User $user, File $file): bool
    {
        return $user->tenant_id === $file->tenant_id
            && $user->can('files.view')
            && (! $file->is_restricted || $user->hasAnyRole($file->allowed_roles));
    }
}

On FileManager, FilePolicy is where the drag-and-drop UI's role-based access controls actually get enforced — not in the Livewire component that renders the file list, which is a UI convenience, but in the one place every download request has to pass through regardless of which screen it came from. The temporary URL expires in minutes deliberately: a link pasted somewhere it shouldn't be still stops working almost immediately, instead of staying valid for however long the bucket's default policy happens to allow.

3. Processing — scanning, thumbnails, transcoding — runs off the request thread#

Generating a thumbnail, scanning for malware, or transcoding a video are all things a naive implementation does synchronously inside the upload request, because that's the easiest place to write the code. It's also the slowest possible place to run it: the customer's browser sits there waiting on work that has nothing to do with whether the upload itself succeeded.

php
class HandleFileUpload
{
    public function handle(UploadedFile $upload, int $tenantId): File
    {
        $path = $this->tenantFileService->pathFor($upload->getClientOriginalName());
        Storage::disk('s3')->putFileAs('', $upload, $path);

        $file = File::create([
            'tenant_id' => $tenantId,
            'path' => $path,
            'original_name' => $upload->getClientOriginalName(),
            'mime_type' => $upload->getMimeType(),
            'size' => $upload->getSize(),
            'status' => 'processing',
        ]);

        Bus::chain([
            new ScanFileForMalware($file->id),
            new GenerateThumbnails($file->id),
            new ExtractFileMetadata($file->id),
        ])->catch(function () use ($file) {
            $file->update(['status' => 'quarantined']);
        })->dispatch();

        return $file; 
    }
}

The upload request returns the moment the bytes are safely on S3 and a File row exists with status = processing — the UI can show that state immediately and update it via a broadcast event when the chain finishes, the same pattern covered in my piece on real-time features in Laravel. Chaining the jobs, rather than dispatching them independently, guarantees a thumbnail is never generated from a file that hasn't passed the malware scan yet — order matters, and Bus::chain is what enforces it without a hand-rolled state machine.

php
class ScanFileForMalware implements ShouldQueue
{
    public $tries = 2;

    public function handle(): void
    {
        $file = File::findOrFail($this->fileId);
        $localPath = Storage::disk('s3')->path($file->path);
        $tmpFile = tempnam(sys_get_temp_dir(), 'scan_');
        file_put_contents($tmpFile, Storage::disk('s3')->get($file->path));

        $result = Process::run("clamdscan --no-summary {$tmpFile}");
        unlink($tmpFile);

        if (str_contains($result->output(), 'FOUND')) {
            Storage::disk('s3')->delete($file->path);
            $file->update(['status' => 'rejected', 'rejection_reason' => 'malware_detected']);
            throw new FileRejectedException("Malware detected in file {$file->id}");
        }
    }
}

On SafetySpace, where evidence photos and PDFs are uploaded directly from job sites — often from devices outside the company's own IT controls — this isn't a theoretical control. It's the step that stops a compromised phone's uploaded photo from ever reaching another user's browser.

4. Compliance needs an audit trail and a lifecycle, not just storage#

SafetySpace's incident evidence isn't just "stored" — it has to be attributable to who uploaded it and when, retained for as long as the relevant safety regulation requires, and provably unaltered if it's ever referenced in a dispute. That's a different requirement from "the file is on S3 somewhere," and it doesn't happen automatically.

php
class FileAuditLog extends Model
{
    protected $casts = ['metadata' => 'array'];
}

class LogFileAccess
{
    public function handle(FileAccessed $event): void
    {
        FileAuditLog::create([
            'file_id' => $event->file->id,
            'user_id' => $event->user->id,
            'tenant_id' => $event->file->tenant_id,
            'action' => $event->action, // viewed, downloaded, deleted
            'ip_address' => $event->ipAddress,
            'metadata' => ['user_agent' => $event->userAgent],
        ]);
    }
}
php
// S3 lifecycle rule, applied per tenant prefix — not a global bucket setting
[
    'ID' => 'incident-evidence-retention',
    'Filter' => ['Prefix' => "tenants/{$tenantId}/incidents/"],
    'Status' => 'Enabled',
    'Transitions' => [
        ['Days' => 90, 'StorageClass' => 'GLACIER'],
    ],
    'Expiration' => ['Days' => 2555], // 7 years, matches the retention requirement
]

Object Lock in compliance mode, applied to the incident-evidence prefix specifically rather than the whole bucket, is what makes "provably unaltered" actually true — without it, "we have an audit log saying nobody deleted it" is a much weaker claim than "S3 physically refused the delete request."

Step-by-Step: Building the Pipeline End to End#

  1. Upload direct to S3 from the browser, not through your app server. A large video file for SignageFlow shouldn't tie up a PHP-FPM worker for the full duration of the upload. Generate a scoped presigned POST that only accepts the exact tenant path, content type, and size the request is allowed to have:
php
public function presignedUpload(Request $request)
{
    $this->authorize('create', File::class);

    $path = $this->tenantFileService->pathFor($request->input('filename'));

    $conditions = [
        ['content-length-range', 0, 500 * 1024 * 1024], // 500MB cap
        ['starts-with', '$key', "tenants/{$this->tenantContext->id()}/"],
    ];

    $presigned = Storage::disk('s3')->temporaryUploadUrl(
        $path,
        now()->addMinutes(10),
        ['Conditions' => $conditions]
    );

    return response()->json(['url' => $presigned['url'], 'headers' => $presigned['headers'], 'path' => $path]);
}
  1. Validate before you trust anything the client claims. Never take a client-reported MIME type at face value — check magic bytes server-side once the upload lands, and reject anything where the declared type and the actual file signature disagree:
php
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$actualMime = finfo_file($finfo, $localPath);

if (! in_array($actualMime, $file->allowed_mimes_for_context)) {
    throw new InvalidFileTypeException("Declared {$file->mime_type}, detected {$actualMime}");
}
  1. Chain the processing pipeline as shown above — scan, then thumbnail, then metadata extraction — and update status at each stage so the frontend can reflect real progress instead of a spinner with no information behind it.
  1. Serve reads exclusively through the policy-checked, time-limited signed URL endpoint, never a stored public link. For SignageFlow's read-heavy screen fleet, front the signed S3 URLs with CloudFront and use signed cookies scoped to a session rather than re-signing a URL per asset per screen refresh — it keeps the CDN cache-friendly while still expiring on a schedule you control.
  1. Make bulk actions transactional and per-item authorized — never a raw ID list passed straight to a delete query. FileManager's bulk actions (delete, move, restore) look like a single user action but are N authorization decisions:
php
class BulkDeleteFiles
{
    public function handle(array $fileIds, User $user): array
    {
        $results = ['deleted' => [], 'denied' => []];

        DB::transaction(function () use ($fileIds, $user, &$results) {
            $files = File::whereIn('id', $fileIds)->where('tenant_id', $user->tenant_id)->get();

            foreach ($files as $file) {
                if ($user->cannot('delete', $file)) {
                    $results['denied'][] = $file->id;
                    continue; 
                }
                Storage::disk('s3')->delete($file->path);
                $file->delete();
                $results['deleted'][] = $file->id;
            }
        });

        return $results;
    }
}

Scoping the initial query by tenant_id — not trusting that every ID in the incoming array already belongs to the requesting tenant — is what stops a manipulated request body from even attempting a cross-tenant delete, regardless of what the per-file policy check would have caught anyway. Defense at both layers, not one or the other.

Pitfalls I've Seen Cost Real Incidents#

A public-read bucket with "unguessable" filenames. This isn't access control, it's obscurity — the first time a link leaks anywhere outside the app, it's permanently accessible to whoever has it. Every read needs a policy check behind it, full stop.

Storage paths with no tenant boundary in them. A tenant_id column that every query filters correctly is not the same guarantee as a path that structurally can't resolve to another tenant's prefix. The one time a query forgets the filter — a queued job, a report export, an admin tool — the path is the only thing left protecting the data.

Synchronous thumbnail generation and virus scanning inside the upload request. Every user-facing upload becomes as slow as the slowest processing step, and a scanning service hiccup turns into a failed upload instead of a job that retries in the background.

Trusting the client's declared MIME type. A .jpg extension and an image/jpeg content-type header don't mean the bytes are actually a JPEG — checking magic bytes server-side is what actually enforces the file-type restriction, not the accept attribute on an HTML input.

Signed URLs with no expiry, or an expiry measured in days. A five-minute signed URL that gets re-requested by the frontend on each view is mildly less convenient to implement than a week-long one. It's also the difference between a leaked link mattering for five minutes and mattering for a week.

Bulk operations that trust a raw list of IDs from the request body. Scoping the query by tenant before checking per-item authorization stops a manipulated bulk-delete request from even reaching the policy layer for files it should never have known existed.

No lifecycle policy, so every uploaded file sits in the expensive storage tier forever. SafetySpace's incident evidence needs years of retention for compliance — it doesn't need years of STANDARD-tier S3 pricing. Transitioning to GLACIER after the active-use window closes is a config change, not an engineering project, and it's the difference between storage cost scaling linearly with tenant count and scaling linearly with active tenant usage. This is easily automated using a custom S3 lifecycle rule.

Key Takeaways#

Secure multi-tenant file storage isn't "add an S3 bucket" — it's four disciplines that all have to hold together: tenant isolation baked into the storage path itself, every read gated by a policy check and a short-lived signed URL, processing pipelines that run off the request thread in a guaranteed order, and a retention story for the data that has to survive longer than the incident that created it.

  • Storage paths carry the tenant boundary structurally — tenants/{tenantId}/... — so a missed database filter isn't the only thing standing between two tenants' files
  • No public buckets, no permanent links — every read goes through an authorization check and a signed URL measured in minutes, not days
  • Malware scanning, thumbnailing, and metadata extraction run as a chained background job, in a guaranteed order, off the request thread
  • Client-declared MIME types are never trusted — magic bytes are checked server-side before a file is treated as what it claims to be
  • Bulk actions scope their initial query by tenant and authorize per item inside a transaction, not by trusting a raw ID list
  • Lifecycle policies move data to cold storage on a schedule that matches actual compliance retention, not indefinitely on the expensive tier

I've built this discipline into a cloud file management product with role-based access controls handling everyday business documents, a real-time signage platform serving high-bandwidth media to device fleets across customers, and an AI safety platform where uploaded evidence has to be attributable, access-controlled, and retained for years. The file types change; the requirement that one tenant's storage can never leak into another's doesn't.

If your product is scaling past the point where "it's an S3 bucket with UUIDs" was ever actually a security model, get in touch about your storage architecture or see the full case studies from platforms where these patterns are running today.

Share this technical insight with your network

Share to LinkedIn or Facebook with key takeaways, featured media, and direct links.

📁 Production Case Study

Case Study: Building Imperial Votes: A Multi-Tenant Next.js & Prisma Competition Operating System

Imperial Votes is a custom online voting platform for beauty pageants, modeling competitions, and organizer-led public contests. I built the product from scratch, starting from a blank Next.js application and turning it into a full SaaS-style platform with public competition pages, contestant profiles, paid voting, leaderboards, organizer dashboards, super-admin controls, role-based permissions, contestant applications, entry fee payments, judging workflows, branding tools, analytics, reports, notifications, and leaderboard graphic generation. The basic product idea was simple: organizers create competitions, contestants compete, voters buy vote bundles, and the leaderboard updates. The actual product became much bigger than that. Imperial Votes needed to support real organizers, real money, real applicants, different countries and currencies, different payment gateways, sponsor visibility, custom competition branding, membership tiers, internal admin workflows, and marketing tools that help organizers promote their contests. My work covered the entire platform: database architecture, backend APIs, payment logic, frontend dashboards, public pages, security and permissions, operational tooling, reporting, exports, image handling, email flows, real-time notifications, and polished UI features such as canvas-based leaderboard graphics.

Related Technical Articles

View all articles →
✦ Let's Build Together

Have a complex technical project in mind?

Available for full-stack engineering, performance audits, cloud deployments, and high-concurrency systems architecture.

Need a web or software development partner?

Tell me what you’re building, what’s getting in the way, and where you need help. Whether you need a custom web application, SaaS platform, API integration, or full-stack development, I’ll give you a clear answer on scope, cost, and timeline usually within one business day.

AqibJavaid

Senior Full-Stack Engineer building backend systems, cloud infrastructure and product platforms for teams that need them to stay up.

Available for new projects

Get in touch

© 2026 Aqib Javaid. All rights reserved.

Built and maintained by Aqib Javaid