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:
- Privacy: Only authorized people can retrieve a sensitive file.
- 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,
],
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);
}
| 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');
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 }}
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',
]
);
}
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']);
});
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'));
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>
| 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 -Indexesdoes not block a known file URL. -
Options -ExecCGIalone does not disablemod_phpor PHP-FPM. - A file without its executable permission bit may still be interpreted by PHP.
- A
noexecmount 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;
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.
| 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?
- Save metadata: Record owner, generated path, size, type, and processing status.
- Quarantine when required: Mark the document unavailable until scanning or review succeeds.
- Process safely: Use patched libraries and isolated workers with resource limits.
- Authorize retrieval: Recheck the requesting user's permission every time.
- Audit appropriately: Record access decisions without logging document contents or credentials.
- Apply retention: Delete abandoned uploads and expired documents according to product requirements.
- Protect backups: Private production storage is undermined by publicly exposed backups.
- 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
- Original Debug School checklist, section 6
- Laravel filesystem documentation
- Laravel validation documentation
- PHP move_uploaded_file documentation
- Apache core directives
- Apache authorization directives
- OWASP File Upload Cheat Sheet
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)