Configuring Private Storage Disks in Laravel 12
When you build applications handling user uploads like invoices, medical records, or private exports, setting your S3 bucket or local disk to public is an instant security bug. Laravel 12 uses League's Flysystem v3 under the hood. If you're targeting AWS S3, you need league/flysystem-aws-s3-v3 installed via Composer.
Here is a clean config/filesystems.php configuration for a private S3 disk paired with PHP 8.3 environment variables:
'disks' => [property_exists
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => false,
'throw' => true,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'visibility' => 'private',
'throw' => true,
],
],Don't set 'visibility' => 'public' on S3 if your S3 bucket has AWS Block Public Access enabled. Doing so triggers a League\Flysystem\UnableToSetVisibility exception on file uploads because Flysystem sends an x-amz-acl: public-read header during putObject, which AWS immediately rejects with HTTP 403 Access Denied. Keep visibility set to private or omit it entirely, letting your bucket configuration handle default object ownership.
Generating Signed Temporary URLs in PHP 8.3
Once your bucket is private, standard public URLs won't work. Calling Storage::disk('s3')->url('reports/invoice-1042.pdf') yields a plain S3 URL that returns HTTP 403 to any browser. You need temporaryUrl(), which generates an AWS V4 pre-signed URL containing an authentication signature and expiration timestamp.
Here's how to generate pre-signed URLs in a Laravel 12 controller, including custom headers for forced inline previews or custom file names:
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class DocumentController extends Controller
{
public function show(Request $request, string $documentId): JsonResponse
{
$user = $request->user();
$document = $user->documents()->findOrFail($documentId);
// Generate a 15-minute signed URL with forced inline viewing
$url = Storage::disk('s3')->temporaryUrl(
$document->file_path,
now()->addMinutes(15),
[
'ResponseContentType' => 'application/pdf',
'ResponseContentDisposition' => 'inline; filename="' . basename($document->original_name) . '"',
]
);
return response()->json([
'id' => $document->id,
'download_url' => $url,
'expires_at' => now()->addMinutes(15)->toIso8601String(),
]);
}
}Notice the third argument array. Flysystem passes these options directly to the underlying AWS SDK GetObject command. ResponseContentDisposition overrides S3's default download behavior, allowing inline browser previews for PDFs or images without changing the raw object metadata in S3.
The Local Disk Gotcha vs S3 Pre-signed URLs
A common pain point during local testing is that Storage::disk('local')->temporaryUrl() fails out of the box with an InvalidArgumentException: This driver does not support creating temporary URLs exception.
Local disks don't native-sign URLs like AWS S3. To fix this in Laravel 12 without changing code across environments, define a temporary URL builder in your AppServiceProvider boot method:
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
public function boot(): void
{
Storage::disk('local')->buildTemporaryUrlsUsing(
function (string $path, \DateTimeInterface $expiration, array $options) {
return URL::temporarySignedRoute(
'local.temp-file',
$expiration,
array_merge($options, ['path' => $path])
);
}
);
}Then register a corresponding route in routes/web.php that serves the file after verifying the signed URL signature with signed middleware. This lets you write code once against temporaryUrl() and run it locally or on S3 effortlessly.
Consuming Pre-Signed URLs in Next.js 16 and React 19
Once your backend returns pre-signed S3 URLs, client-side rendering in React 19 or Next.js 16 requires proper CORS handling on AWS. S3 will block browser requests originating from your Next.js domain unless configured.
Add this CORS rule to your S3 bucket configuration in the AWS Console or via Terraform:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedOrigins": ["https://yourdomain.com", "http://localhost:3000"],
"ExposeHeaders": ["ETag", "Content-Length", "Content-Type"],
"MaxAgeSeconds": 3600
}
]Here is a clean React 19 component inside a Next.js 16 App Router setup that fetches the temporary URL from your Laravel API and displays or downloads the secure file directly from S3:
'use client';
import { useState, useEffect } from 'react';
interface DocumentViewerProps {
documentId: string;
}
export default function DocumentViewer({ documentId }: DocumentViewerProps) {
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
async function fetchSignedUrl() {
try {
const response = await fetch(`/api/documents/${documentId}`);
if (!response.ok) {
throw new Error(`Failed to load document: ${response.statusText}`);
}
const data = await response.json();
setDownloadUrl(data.download_url);
} catch (err: any) {
setError(err.message || 'Error fetching document link');
} finally {
setLoading(false);
}
}
fetchSignedUrl();
}, [documentId]);
if (loading) return <p>Loading secure document...</p>;
if (error) return <p className="text-red-500">{error}</p>;
return (
<div className="p-4 border rounded shadow-sm">
<h3 className="font-bold text-lg">Secure Attachment</h3>
<div className="mt-2">
<a
href={downloadUrl!}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 inline-block"
>
View Document
</a>
</div>
</div>
);
}Production Gotchas: Clock Skew and Expiration Bounds
Two critical bugs catch developers off guard in production when dealing with laravel s3 signed url implementations.
1. Clock Skew (RequestTimeTooSkewed)
AWS pre-signed URLs rely heavily on precise timestamps. If your application server clock drifts even 15 seconds ahead of AWS system time, AWS S3 throws a RequestTimeTooSkewed error when browsers attempt to access the generated URL. Ensure your EC2 instances or Docker containers run chrony or systemd-timesyncd synchronized with NTP servers.
2. Max Expiration Limit (7 Days)
The maximum lifetime for AWS IAM role-based temporary credentials (like those used on ECS tasks, EKS pods, or EC2 instance profiles) caps pre-signed URL lifetimes at the lifetime of the STS session—often 1 hour, max 12 hours. Even if you request now()->addDays(3), S3 will reject the URL after the temporary IAM credentials expire. Keep your signed URL TTLs short—5 to 15 minutes is ideal for user browser actions.










