Laravel S3 Presigned Uploads: CORS and Private Files

Build private Laravel S3 uploads with signed URLs, correct CORS, owner authorization and server-side content checks that resist upload URL reuse.

Adrian Silaghi

8 min read

0 views

LaravelS3File UploadsCORSApplication SecurityObject Storage
Laravel S3 Presigned Uploads: CORS and Private Files

Use Laravel to authorize an upload and issue a short-lived S3 PUT URL, then validate the stored bytes before making the file available. Keep the bucket private, generate object keys on the server and authorize downloads separately. CORS lets a browser use the storage endpoint; it does not decide who owns a file.

This guide uses Laravel 12's S3 disk API and a small-file flow for PNG and JPEG attachments. The design separates an untrusted staging object from an approved private object, so a reusable upload URL cannot replace a file after approval. It applies to AWS S3 and can be adapted to another S3-compatible provider after testing the required capabilities.

Define three separate application actions

  1. Issue: authenticate the user, authorize the destination resource, check the declared type and size, and create an upload record with a random staging key.
  2. Complete: accept the upload record's ID, verify its owner, inspect the actual bytes, and store the accepted content under a different server-only key.
  3. Download: authorize the record again and issue a short-lived GET URL for its final key.

Store at least the upload ID, owner, resource association if applicable, staging key, expected size, allowed type, expiry and state. The browser must never choose another user's prefix or submit an arbitrary object key for finalization. Use a database transaction and record lock to serialize competing completion requests, and make successful completion idempotent.

Download the runnable example and local checks. It includes the complete small upload service, Laravel route adapter, browser helper and CORS example. Its checks use local storage and real Laravel URL signing with example credentials; they do not establish compatibility with a live bucket.

Configure a private S3 disk

Install Laravel's S3 adapter in your application using your normal dependency workflow:

composer require league/flysystem-aws-s3-v3 "^3.0"

Keep access credentials in server configuration or your secret manager. The browser receives a signed URL and its required headers, never the secret key. Restrict the server identity to the bucket and operations the application needs. Configure TLS, endpoint, region and path-style addressing according to your provider rather than copying another service's values.

// config/filesystems.php: an additional disk
'private_uploads' => [
    'driver' => 's3',
    'key' => env('UPLOAD_S3_KEY'),
    'secret' => env('UPLOAD_S3_SECRET'),
    'region' => env('UPLOAD_S3_REGION'),
    'bucket' => env('UPLOAD_S3_BUCKET'),
    'endpoint' => env('UPLOAD_S3_ENDPOINT'),
    'use_path_style_endpoint' => env('UPLOAD_S3_PATH_STYLE', false),
    'throw' => true,
],

The Laravel filesystem documentation covers S3-compatible endpoints and temporary upload URLs. Keep bucket access private through the actual storage policy. A random filename and an unpublished bucket URL are not access controls.

On AWS, new buckets normally use Bucket owner enforced, with ACLs disabled. Such buckets accept uploads without an ACL or with bucket-owner-full-control, while other ACL values can produce AccessControlListNotSupported. The downloadable service uses that accepted canned ACL for its server-side final write; it grants no public access. Keep the private bucket policy and verify another provider's behavior. See AWS Object Ownership.

Authorize the resource before generating a URL

Put issuance behind your existing authentication, CSRF protection for cookie-authenticated requests, and rate limiting. Where uploads belong to a project or document, use its policy before creating the record. Laravel's authorization guide explains gates and policies; signing storage access does not replace them.

The following signing excerpt assumes $upload is a newly persisted, authorized record, with its server-generated staging key and validated declared type:

use Illuminate\Support\Facades\Storage;

['url' => $url, 'headers' => $headers] = Storage::disk('private_uploads')
    ->temporaryUploadUrl(
        $upload->staging_key,
        now()->addMinutes(5),
        ['ContentType' => $upload->declared_mime],
    );

return response()->json([
    'id' => $upload->id,
    'url' => $url,
    'headers' => $headers,
]);

Allow only the types your feature needs, and reject an out-of-policy declared size before issuance. For this example the limit is 5 MiB. These checks do not prove what the browser will upload: a plain presigned PUT is not a general content-length policy. Oversized or abandoned objects may consume storage before finalization rejects them, so plan quotas, issuance limits and staging cleanup.

AWS documents that a presigned upload can replace an existing object at the same key. Generate a new unpredictable key for each attempt instead of reusing the user's filename. Preserve the original filename only as separately validated display metadata. See presigned object uploads.

Send exactly the signed upload request

After your application returns the ticket, send the file directly to storage. This function assumes the application's authenticated issuance request already succeeded:

async function putFile(ticket, file) {
  const response = await fetch(ticket.url, {
    method: 'PUT',
    headers: ticket.headers,
    body: file,
    credentials: 'omit',
  });
  if (!response.ok) {
    throw new Error(`Storage rejected the upload (${response.status})`);
  }
}

Do not add the application's Authorization header or session cookies to the storage request. Do not wrap the file in FormData for this PUT flow: that changes the body. Preserve signed headers and their values. An HTTP error does not automatically reject a Fetch promise, which is why the code checks response.ok; see the Fetch API guide.

After a successful PUT, call your authenticated completion endpoint with the upload ID. A successful storage response alone must not mark the application record ready.

Configure CORS for the application origin

A browser's preflight must match the bucket rule's origin, method and requested headers. This illustrative AWS-style rule allows your app to upload and fetch objects:

[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["PUT", "GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 300
  }
]

Replace the origin and narrow allowed headers once you know the real signed request. A wildcard header rule is separate from a wildcard origin or public bucket policy. OPTIONS is not listed as an S3 allowed method; the service handles preflight. AWS's CORS element reference explains the accepted fields.

Diagnose failures in order: is the browser using the expected endpoint, does the preflight match, then does the actual signed PUT succeed? A request working in curl does not prove browser CORS is correct. Conversely, a CORS error can hide an underlying forbidden response. CORS does not grant storage permissions; bucket policies still apply.

Inspect the content and remove the overwrite race

On completion, fetch the record by upload ID and authenticated owner, confirm it is pending and unexpired, and read the server-recorded staging object. Read at most the allowed size plus one byte, rather than loading an unbounded object into PHP memory. Compare the actual count with both the maximum and declared size.

Use the bytes for type inspection, not the uploaded Content-Type metadata. For example:

$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($bytes);
$allowed = ['image/png', 'image/jpeg'];
if (!in_array($mime, $allowed, true)) {
    throw new DomainException('Upload content rejected');
}

PHP Fileinfo can inspect a buffer, but MIME detection alone does not certify a file as safe. The example also checks image dimensions; add appropriate decoding, re-encoding or malware scanning before previews or downstream processing. The OWASP upload guidance recommends several complementary checks and generated storage names.

Store the inspected bytes under a fresh final key that was never included in a browser PUT URL, then atomically record that key as ready. Do not inspect staging and later copy it without binding the copy to the inspected object: a still-valid upload URL could replace it between those operations. The bounded example writes the already inspected bytes, eliminating that second read.

Account for failure between object storage and the database. A successful object write followed by a failed database commit can leave an orphan; keep it private and reconcile it later. Expire abandoned staging objects with a suitable lifecycle or cleanup job. Never delete a key merely because one completion request failed while another may have succeeded.

Authorize every private download

Only ready records should produce a download URL. Look up the final key from the authorized record, use a short expiry and prefer attachment delivery for untrusted files. Do not accept a raw key from the request and sign it. Treat signed URLs as bearer capabilities: anyone holding one can use it while valid, and AWS allows reuse until expiry. See presigned URL behavior.

Keep URLs out of analytics payloads and ordinary logs. For sensitive content, consider whether short expiry is sufficient or whether downloads should pass through an application endpoint with immediate authorization. Test expired URLs, missing files, another user's IDs, forged MIME, oversized bytes, duplicate completion and a replaced staging object.

Before deploying to an S3-compatible provider, verify PUT signing, required headers, browser CORS, private bucket access, HEAD/GET behavior, object ownership, response-header overrides and lifecycle support. Our S3 security guide and versioning guide cover related controls. Explore DanubeData object storage and validate the capabilities your application needs against the selected service.

Frequently asked questions

Does validating file.size stop large uploads?

No. It checks a browser-supplied claim. Validate actual stored bytes before acceptance and use provider-supported ingress controls where a hard upload-size ceiling is required.

Can I fix CORS by making the bucket public?

That changes access permissions and does not repair the request's origin or headers. Keep storage private and fix the matching CORS rule and signed request.

Is a presigned URL single-use?

Not on AWS S3. It can be reused before expiry. Separate writable staging keys from final approved keys and make application completion idempotent.

Will this work unchanged on every S3-compatible service?

No. Addressing, signing requirements, ACL handling and policy features vary. Use the same security flow, then run integration checks against the exact endpoint.

Sources

Related tutorials

Browse all articles
Tutorials9 min read

Laravel Redis to Valkey: A Safe Migration Guide

Migrate Laravel from Redis to Valkey with version checks, cache and session planning, queue draining, a tested local fixture and a clear recovery boundary.

Adrian Silaghi0 views
Tutorials9 min read

Serverless Webhooks with a Durable PostgreSQL Inbox

Build a serverless webhook receiver that authenticates requests, saves events before acknowledging them, and processes a durable PostgreSQL inbox safely.

Adrian Silaghi0 views

Ready to get started?

Deploy your infrastructure in minutes with DanubeData's managed services.