Laravel / Advanced / Lesson 16
Secure file storage and authorized downloads
Separate upload validation, private storage identity, access authorization, and download delivery so a convenient path never becomes a substitute for permission.
On this page
Lesson goals
- Validate accepted content and size without trusting client filenames.
- Store server-generated keys separately from display names.
- Authorize every private-file access and test its failure paths.
Define privacy before choosing a disk
The synthetic application accepts one PDF attachment up to 2048 kilobytes and stores it on a local disk rooted outside the public web directory. A Document record contains tenant_id, owner_id, storage_key, and display_name; its view policy decides who can download. These are declared fixture assumptions, not claims about an existing configured application. Public visibility and a web-accessible storage link are unsuitable for confidential documents. An unguessable filename reduces guessing but does not establish authorization. Treat storage identity, presentation name, content validation, and permission as separate pieces of the contract.
Never let a client filename choose the object identity
Laravel upload storage can generate a filename and return a disk-relative key. Persist that key with the chosen disk rather than rebuilding it from a later request parameter. The complete PHP model below validates an already server-assigned identifier and constructs one tenant-scoped key without accessing any file. It is an identity model, not an upload handler, UUID generator, or authorization mechanism. A real identifier allocator must prevent collisions according to the application design. Keep any original filename as untrusted display metadata and choose a safe response filename separately. Do not resolve a user-provided traversal path against private storage.
<?php
declare(strict_types=1);
function documentKey(int $tenantId, string $serverId): string
{
if ($tenantId < 1 || preg_match("/^[a-z0-9-]{1,32}$/D", $serverId) !== 1) {
throw new InvalidArgumentException("invalid storage identity");
}
return "tenant/".$tenantId."/documents/".$serverId.".pdf";
}
echo documentKey(7, "abc-123"), PHP_EOL;
try { documentKey(7, "../private"); }
catch (InvalidArgumentException) { echo "rejected", PHP_EOL; }
tenant/7/documents/abc-123.pdf
rejected
The valid identifier produces the displayed relative key. The traversal-shaped identifier is rejected; no filesystem path is opened or changed.
Validate the upload before storage
The contextual request fragment requires the file, uses Laravel’s content-based MIME inference for the allowed PDF type, and applies the fixture size bound. A MIME rule does not certify that a PDF is harmless or fulfill a separate malware-inspection policy. Original extensions and browser-provided content types are not sufficient evidence. Store only after authorization and validation, and handle a false result or configured write exception before persisting a successful document record. A database transaction cannot automatically roll back a filesystem write, so define cleanup for partial failures and orphaned objects. This lesson executes none of those storage operations.
<?php
use Illuminate\Validation\Rules\File;
$request->validate([
"attachment" => ["required", File::types(["pdf"])->max(2048)],
]);
$storageKey = $request->file("attachment")->store("reports", "local");
if ($storageKey === false) {
throw new RuntimeException("file storage failed");
}
Expected isolated-fixture observation: a permitted small PDF receives a generated reports/ key; invalid type or size is rejected before writing. The exact generated name is intentionally not fabricated.
Check the policy before exposing bytes
The download fragment uses route-resolved Document data and Gate authorization before requesting bytes by its stored key. Do not accept a raw storage key as proof that the caller may access a document. Use tenant-scoped lookup as appropriate and still evaluate the document policy. A temporary URL is a time-limited capability whose disclosure and validity need a policy; creating one does not replace the permission check that precedes it. Verify driver support and configuration before relying on temporary URLs. Set download presentation deliberately and avoid exposing absolute paths, internal error details, or attacker-controlled response headers.
<?php
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
Gate::authorize("view", $document);
return Storage::disk("local")->download($document->storage_key, "report.pdf");
Expected observation in an authorized application test: a denied actor receives no file response; an allowed actor downloads the stored object using a fixed display filename. This fragment is not run here.
Test denied access, storage failures, and lifecycle
An isolated Storage::fake disk can verify upload and deletion intent without touching real disks, while HTTP tests verify validation and policy responses. Fakes do not prove object-store access control, signed-URL behavior, or cloud availability. Test cross-tenant requests, missing records, missing objects, invalid content, oversized uploads, and write failure. Define retention and deletion for records and objects together, including audit requirements and retries. Avoid logging uploaded bytes or full sensitive paths. A safe download path must remain correct after rename, replacement, and permission revocation; a one-time successful upload is not the entire security lifecycle.
Common errors and fixes
A private document is reachable through a public storage URL.
- Why it happens
- Disk exposure was chosen without the confidentiality contract.
- How to fix it
- Use private storage and an authorized delivery path.
- Prevent it next time
- Test direct and cross-tenant access, not only upload success.
A client filename influences a private path.
- Why it happens
- Display metadata was treated as trusted object identity.
- How to fix it
- Persist a server-generated disk-relative key.
- Prevent it next time
- Keep path selection independent of request filenames.
A record claims an upload succeeded after storage failed.
- Why it happens
- The write result and cross-resource failure path were ignored.
- How to fix it
- Handle failure before success persistence and clean partial state.
- Prevent it next time
- Test fake write failures and orphan-cleanup policy.
Check your understanding
1. A user changes the download request to another tenant’s known document identifier. Explain why an unguessable storage key is insufficient.
Show worked solution
The request can reach an application record without guessing its storage key. The lookup and policy must enforce the actor’s scope before any download or temporary URL is created. Test a known foreign identifier and assert a denied response with no object bytes. Do not expose whether a private object exists when the application policy requires an indistinguishable denial.
2. The file write succeeds but inserting Document fails. Describe a recovery contract without claiming a database rollback removes the file.
Show worked solution
Track the newly created storage key and attempt authorized cleanup when the record cannot be committed. If cleanup fails, record a bounded recoverable orphan task with enough identity for later reconciliation. Keep the external write and database transaction as separate resources. Tests should simulate both the record failure and cleanup failure, then verify no false success response is returned.
Key takeaways
- Private identity, validation, authorization, and delivery differ.
- Never trust client filenames as storage paths.
- Test file and record lifecycle failures separately.