Debug School

rakesh kumar
rakesh kumar

Posted on

Store Uploads Privately and Prevent Execution: A Practical PHP and Laravel Security Guide

Uploading a file should never automatically make it public or allow it to become executable application code. This guide explains what private uploads mean, why they matter, when to use them, where to configure them, how to implement them, and what should happen afterward.

The examples expand on section 6 of the Laravel PHP Security Checklist on Debug School. They are illustrative implementation patterns; adapt paths, authentication, policies, and storage limits to your application.

1. What does “store uploads privately and prevent execution” mean?

It means enforcing two separate protections:

  1. Privacy: Only authorized people can retrieve a sensitive file.
  2. Execution prevention: Uploaded content is treated as data and never interpreted as server application code.

For example, a patient's medical report must not be accessible merely because someone knows its URL. A file uploaded as a document must never become a PHP program executed by the website.

Term Simple meaning Example
Web root Directory the web server exposes to visitors /services/myapp/public
Private upload storage Storage outside direct public access /services/myapp/storage/app/private/uploads
Authentication Identify the user User signs in
Authorization Check permission for a particular action Patient may download their own report
Script execution Interpret file content as code PHP processes a script
File download Return file bytes to a user Browser downloads a PDF

A folder called private is not automatically secure. An Apache alias, public symlink, object-storage policy, or application endpoint can still expose it.

2. Why is this important for security?

Security objective Risk without protection Role of private storage and execution prevention
Confidentiality Another person reads KYC or medical documents Prevent direct public retrieval and check permissions
Integrity Attacker-controlled filenames overwrite or confuse stored files Generate storage names and restrict writable locations
Server protection An uploaded script is interpreted by PHP Separate uploaded data from executable application code
Access control Changing a document ID exposes another user's file Authorize each record before download
Availability Unlimited uploads exhaust disk space Apply quotas, retention, cleanup, and upload limits
Accountability Sensitive document access cannot be investigated Record appropriate upload/download audit events

Private storage is not encryption, malware detection, or a complete defense against server compromise. It is an access boundary. Other controls address other risks.

3. When should uploads be private?

File category Recommended access Reason
Patient reports and prescriptions Private Sensitive health information
Driving licences and KYC documents Private Personal identity information
Invoices and contracts Usually private Financial or commercial information
Internal exports and backups Private May contain many users' records
Public vehicle listing photos Intentionally public after validation Visitors need to see listings
Public profile photos Public only when the product requires it Visibility is part of the feature

For MotoShare, keep public vehicle photos separate from customer identity documents. For MyHospitalNow, serve patient documents only after the relevant patient or care-team authorization check.

4. Where should the configuration and code go?

Responsibility Typical Laravel location
Configure storage disks config/filesystems.php
Validate uploads app/Http/Requests/StoreDocumentRequest.php or controller
Store files and metadata Upload controller or application service
Define document access rules app/Policies/DocumentPolicy.php
Protect upload/download routes routes/web.php or routes/api.php
Store ownership and generated path Documents database table
Deny direct web access Apache server or virtual-host configuration
Enforce retention and cleanup Scheduled command or background job

Configure the website's document root as the Laravel public directory, rather than the entire project. Do not expose private storage through an alias or symlink.

5. How do you configure private Laravel storage?

Add a named disk inside the existing disks array in config/filesystems.php:

'private_uploads' => [
    'driver' => 'local',
    'root' => storage_path('app/private/uploads'),
    'visibility' => 'private',
    'throw' => true,
],
Enter fullscreen mode Exit fullscreen mode

Syntax, meaning, purpose, and security benefit

Setting Meaning Purpose Security benefit and limitation
'private_uploads' => [...] Names the disk Select it explicitly in application code Reduces accidental use of a public disk
'driver' => 'local' Store on this server Choose the storage backend Local storage is not inherently private
'root' => storage_path(...) Set the base directory Keep uploaded files in one controlled location Outside public/, provided no alternate exposure exists
'visibility' => 'private' Request private filesystem permissions Limit filesystem access Does not decide which application user owns a file
'throw' => true Throw on failed writes Handle failure instead of reporting success Does not scan or validate content

Laravel versions and existing projects can have different default disk configurations. An explicit named disk avoids relying on an assumed default. Never point a public storage link at this private directory.

6. How do you upload a document safely in Laravel?

This example assumes a Document model with user_id, path, and original_name columns. Include the methods in your controller and adapt model namespaces as needed.

use App\Models\Document;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

public function store(Request $request)
{
    $request->validate([
        'document' => [
            'required',
            'file',
            'mimes:pdf',
            'max:5120',
        ],
    ]);

    $file = $request->file('document');
    $path = $file->store('', 'private_uploads');

    try {
        $document = new Document();
        $document->user_id = $request->user()->id;
        $document->path = $path;
        $document->original_name = $file->getClientOriginalName();
        $document->save();
    } catch (\Throwable $exception) {
        Storage::disk('private_uploads')->delete($path);
        throw $exception;
    }

    return response()->json(['id' => $document->id], 201);
}
Enter fullscreen mode Exit fullscreen mode
Syntax Meaning Security purpose
required A file must be present Reject missing input
file Require a successfully uploaded file Reject invalid upload values
mimes:pdf Infer file type from content and allow PDF Restrict accepted formats; does not prove the PDF is harmless
max:5120 Maximum 5,120 KB, approximately 5 MiB Bound accepted size
store('', 'private_uploads') Store at the disk root with a generated filename Avoid using a supplied filename as the destination
user_id Record the authenticated owner Support later authorization
original_name Retain the submitted name as metadata Keep it separate from storage paths

Protect this endpoint with authentication, applicable upload permissions, CSRF protection for session-based forms, rate limits, and per-user storage quotas. Do not accept an owner ID from the client as proof of ownership.

The cleanup above handles an ordinary database failure. A process crash can still leave an orphan file, so periodically reconcile abandoned uploads. Cleanup failures should be logged and retried.

Why generate the filename?

// Avoid using client-controlled names as storage destinations.
$name = $request->file('document')->getClientOriginalName();

// Prefer Laravel's generated storage filename.
$path = $request->file('document')->store('', 'private_uploads');
Enter fullscreen mode Exit fullscreen mode

Generated names reduce collisions and exposure to dangerous filename handling. They do not replace authorization. Treat the original filename as untrusted text; escape it when displayed:

{{ $document->original_name }}
Enter fullscreen mode Exit fullscreen mode

7. How should private downloads work?

The client requests a document ID. The server retrieves its stored record, checks permission, and returns its bytes.

use App\Models\Document;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

public function download(Request $request, Document $document)
{
    abort_unless(
        (string) $document->user_id === (string) $request->user()->id,
        403
    );

    $disk = Storage::disk('private_uploads');
    abort_unless($disk->exists($document->path), 404);

    return $disk->download(
        $document->path,
        'document.pdf',
        [
            'Content-Type' => 'application/pdf',
            'X-Content-Type-Options' => 'nosniff',
            'Cache-Control' => 'private, no-store',
        ]
    );
}
Enter fullscreen mode Exit fullscreen mode
use App\Http\Controllers\DocumentController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->group(function () {
    Route::post('/documents', [DocumentController::class, 'store'])
        ->middleware('throttle:10,1');

    Route::get('/documents/{document}/download',
        [DocumentController::class, 'download']);
});
Enter fullscreen mode Exit fullscreen mode

Use the authentication middleware configured for your application; token APIs may use auth:sanctum or another guard. The download example allows only the owner. Use a policy for administrators, hospitals, or team sharing.

Control Purpose Limitation
auth Require a signed-in user Login alone does not grant access to every file
Owner check or policy Authorize the specific document Must run for every private retrieval
download(...) Return an attachment response Does not replace a permission check
nosniff Tell browsers not to guess a different content type Does not remove malicious content
private, no-store Instruct caches not to retain sensitive responses Does not revoke copies already downloaded

Never let the user choose a filesystem path

// Dangerous: user input selects a server file.
return response()->download($request->query('path'));
Enter fullscreen mode Exit fullscreen mode

Use the authorized document record's stored path. Never expose a general-purpose file reader. A UUID or an obscure URL is not an access-control mechanism.

8. How do you prevent execution with Apache?

For private uploads, first keep the directory outside the web root. As additional protection, deny direct HTTP access to it in the relevant Apache 2.4 configuration:

# Example absolute path: replace with the actual directory.
<Directory "/services/myapp/storage/app/private">
    AllowOverride None
    Options -Indexes -ExecCGI
    Require all denied
</Directory>
Enter fullscreen mode Exit fullscreen mode
Directive Meaning Purpose and security role
<Directory "..."> Apply rules to a filesystem directory Scope protection to the actual storage location
AllowOverride None Ignore .htaccess inside this directory Prevent uploaded configuration files from changing these rules
Options -Indexes Disable automatic directory listing Reduce accidental filename disclosure
Options -ExecCGI Disable execution enabled by the CGI option One restriction; does not disable every PHP handler
Require all denied Deny direct HTTP access Block visitors from requesting private files directly

Important distinctions:

  • Options -Indexes does not block a known file URL.
  • Options -ExecCGI alone does not disable mod_php or PHP-FPM.
  • A file without its executable permission bit may still be interpreted by PHP.
  • A noexec mount does not stop an interpreter from reading a script.
  • Rules for a public upload directory depend on the effective handler configuration. Do not assume a short extension blocklist covers every handler or alternative suffix.
  • Apache access rules do not stop application code from reading the directory. The download controller still needs authorization.

For public photos, prefer a dedicated static-serving location or service that has no script handler, and allow only the formats the feature needs. Public HTML and SVG can introduce browser-side script risks even when server-side PHP execution is disabled.

Never interpret uploads as code

// Never do this with untrusted uploaded content.
include $uploadedPath;
require $uploadedPath;
Enter fullscreen mode Exit fullscreen mode

Do not pass user-selected uploads into a PHP interpreter, template compiler, or shell command. Document and image converters should run with restricted permissions and CPU, memory, and time budgets.

9. How do you implement private storage in plain PHP?

This example expects an authenticated and authorized upload endpoint with appropriate CSRF protection. Provision /srv/myapp-private/uploads outside the web root with the correct ownership before using it.

<?php

$file = $_FILES['document'] ?? null;

if (
    !$file ||
    !is_int($file['error'] ?? null) ||
    $file['error'] !== UPLOAD_ERR_OK ||
    !is_string($file['tmp_name'] ?? null)
) {
    http_response_code(400);
    exit('Invalid upload');
}

$tmp = $file['tmp_name'];

if (!is_uploaded_file($tmp)) {
    http_response_code(400);
    exit('Invalid upload source');
}

$size = filesize($tmp);

if ($size === false || $size === 0 || $size > 5 * 1024 * 1024) {
    http_response_code(422);
    exit('Invalid file size');
}

$mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmp);

if ($mime !== 'application/pdf') {
    http_response_code(422);
    exit('Only PDF documents are accepted');
}

$directory = '/srv/myapp-private/uploads';
$name = bin2hex(random_bytes(16)) . '.pdf';

// Restrictive creation permissions for this dedicated endpoint.
umask(0077);

if (!move_uploaded_file($tmp, $directory . '/' . $name)) {
    http_response_code(500);
    exit('Upload failed');
}

// Save $name and the authenticated owner's ID in your database.
// Handle database failures and abandoned-file cleanup.
Enter fullscreen mode Exit fullscreen mode
Function or value Meaning Security role
UPLOAD_ERR_OK PHP reports a successful upload Reject failed or incomplete uploads
is_uploaded_file() Check that the temporary file came from HTTP upload handling Reject an arbitrary local file as the upload source
filesize() Measure temporary file size Enforce the application limit
finfo(...)->file() Infer MIME type from bytes Avoid trusting only a browser-supplied type
random_bytes(16) Generate cryptographically random bytes Create an unpredictable storage name
umask(0077) Remove group/other permissions from newly created objects Reduce filesystem exposure; verify resulting permissions in deployment
move_uploaded_file() Move a PHP-uploaded file to its destination Does not validate content, authorize downloads, or create privacy by itself

move_uploaded_file() can overwrite an existing destination. Random names greatly reduce accidental collisions; use an exclusive storage design when strict non-overwrite guarantees are required.

10. Which production settings are appropriate or risky?

Area Appropriate production pattern Risky pattern Why it matters
Sensitive storage Outside the web root public/uploads/kyc/ Direct retrieval can bypass Laravel
Storage links Expose only intended public assets Symlink private uploads into public/ A link can remove the privacy boundary
Disk selection Explicit private_uploads disk Rely on an unverified default Existing configurations differ
Filename Generated storage name Original filename becomes the path Avoid collisions and unsafe path handling
Authorization Owner or policy check on every download Logged-in users can access all documents Authentication is not authorization
Path input Resolve path from a trusted stored record Accept ?path= from the client Prevent arbitrary file selection
Type validation Feature-specific allowlist Accept every extension or browser MIME type Reduce unexpected content
Script handling No upload directory script handler; never include uploads Treat uploads as templates or PHP Avoid code interpretation
File permissions Typically 0600 or 0640, if ownership supports it Recursive 777 Limit other accounts' access
Directory permissions Typically 0700 or 0750, if ownership supports it World-writable directories Limit modification and traversal
Failure handling Catch failures, log safely, reconcile orphan files Report success after a failed write Maintain consistent file records
Storage budget Per-user quotas, retention, cleanup Unlimited permanent uploads Reduce disk exhaustion
High-risk content Quarantine and scan before release Allow download before required scanning finishes Avoid releasing unapproved content
Downloads Attachment, correct type, appropriate cache controls Sensitive content cached publicly Reduce accidental disclosure

Permission modes are examples, not universal values. The PHP worker, queue worker, scanner, and backup process may have different access requirements. Grant only the access each service needs.

11. Then what should happen after an upload?

  1. Save metadata: Record owner, generated path, size, type, and processing status.
  2. Quarantine when required: Mark the document unavailable until scanning or review succeeds.
  3. Process safely: Use patched libraries and isolated workers with resource limits.
  4. Authorize retrieval: Recheck the requesting user's permission every time.
  5. Audit appropriately: Record access decisions without logging document contents or credentials.
  6. Apply retention: Delete abandoned uploads and expired documents according to product requirements.
  7. Protect backups: Private production storage is undermined by publicly exposed backups.
  8. Verify boundaries: Test direct URLs, alternate users, expired access, invalid files, and failed storage writes.

Practical verification cases

Test Expected result
Owner downloads their document Authorized download succeeds
Another signed-in user requests the same document Denied unless an explicit policy grants access
Anonymous visitor requests the download route Authentication is required
Visitor requests private storage directly Not served; normally 403 or 404
Upload exceeds the allowed size Rejected by configured request/application limits
Unexpected type is uploaded Rejected
Valid PDF contains malicious content Not assumed safe merely because MIME validation passed
Database write fails after file storage Cleanup attempted; residual files reconciled
File is pending mandatory scanning Download remains unavailable

12. Common questions

Why not use storage/app/public for everything?

It is intended for files that may be publicly accessible. Sensitive records need a different access path with authorization.

When is a random filename useful?

It avoids relying on client-controlled paths and reduces collisions. It does not grant or enforce download permission.

How does this relate to out-of-memory errors?

Private storage mainly addresses disclosure and code-execution exposure. Upload size limits, quotas, bounded processing, and worker resource budgets address availability and memory consumption. Both sets of controls are needed.

What if a malicious file passes validation?

Keep it outside executable locations, avoid interpreting it, and apply scanning or quarantine where required. Valid file formats may still contain harmful content or exploit vulnerable processors.

Does private mean encrypted?

No. Encryption at rest and key management are separate decisions. They do not replace authorization either.

References

13. Colorful architecture: private storage and authorized downloads

The blue and green path accepts controlled uploads. The purple storage boundary keeps files outside direct web access. The orange decision checks each download. Red paths deny unauthorized access. Scanning is required only where the application's risk and policy call for it.

flowchart TD
    U["User uploads document"] --> V["Authenticate and validate — Type, size, quota, permissions"]
    V -->|Rejected| X["Reject request"]
    V -->|Accepted| Q["Quarantine and scan — When required"]
    Q -->|Failed| X
    Q -->|Approved| S["Private storage outside web root — Generated filename; never interpret as code"]
    V --> M["Database metadata — Owner, path, status"]
    D["User requests document ID"] --> A{"Authenticated and authorized? — Document approved?"}
    M --> A
    A -->|No| N["Deny download"]
    A -->|Yes| C["Download controller — Resolve stored path"]
    S --> C
    C --> R["Attachment response — Correct type; private cache policy"]
    W["Direct web request to private files"] --> B["Blocked by storage boundary — Apache denies direct access"]

    classDef blue fill:#dbeafe,stroke:#2563eb,color:#172554,stroke-width:2px;
    classDef green fill:#dcfce7,stroke:#16a34a,color:#14532d,stroke-width:2px;
    classDef purple fill:#f3e8ff,stroke:#9333ea,color:#581c87,stroke-width:2px;
    classDef orange fill:#ffedd5,stroke:#ea580c,color:#7c2d12,stroke-width:2px;
    classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px;

    class U,D,W blue;
    class V,Q,C,R green;
    class S,M purple;
    class A orange;
    class X,N,B red;

Diagram rendering requires a Markdown platform with Mermaid support. If the blog platform does not support Mermaid, render the diagram to SVG or PNG before publishing.

Top comments (0)