back to articles
AWS
Backend

Handling Multi-stage Uploads Securely with AWS S3 Presigned URLs

A practical guide to generating and validating presigned URLs for secure, direct-to-S3 uploads — avoiding server bottlenecks while maintaining strict access control.

Jan 2026·7 min read

Traditional file uploads route through your application server, consuming memory and bandwidth. S3 presigned URLs let clients upload directly to S3 while your server retains full access control.

The Three-Phase Flow

  • ▸Phase 1 — Request: Client asks your API for a presigned PUT URL
  • ▸Phase 2 — Upload: Client PUTs directly to S3 (server not involved)
  • ▸Phase 3 — Confirm: Client notifies API; server verifies the object exists

Generating Presigned PUT URLs

php
public function getPresignedUrl(UploadRequest $request): JsonResponse
{
    $this->authorize('upload', Document::class);

    $allowed = ['jpg', 'jpeg', 'png', 'pdf'];
    $ext = $request->input('extension');

    abort_unless(in_array($ext, $allowed, true), 422, 'File type not permitted.');

    $key = sprintf('uploads/pending/%d/%s.%s', auth()->id(), Str::uuid(), $ext);

    $command = $this->s3->getCommand('PutObject', [
        'Bucket'      => config('filesystems.disks.s3.bucket'),
        'Key'         => $key,
        'ContentType' => $request->input('content_type'),
        'ACL'         => 'private',
    ]);

    $url = (string) $this->s3->createPresignedRequest($command, '+15 minutes')->getUri();

    PendingUpload::create(['user_id' => auth()->id(), 'object_key' => $key, 'expires_at' => now()->addMinutes(15)]);

    return response()->json(['upload_url' => $url, 'object_key' => $key]);
}

Phase 3: Server-side Confirmation

Never trust the client claim that an upload succeeded. Always verify the object in S3 and check its size before moving it to a permanent location:

php
public function confirmUpload(ConfirmRequest $request): JsonResponse
{
    $pending = PendingUpload::where('object_key', $request->object_key)
        ->where('user_id', auth()->id())
        ->where('expires_at', '>', now())
        ->firstOrFail();

    try {
        $meta = $this->s3->headObject([
            'Bucket' => config('filesystems.disks.s3.bucket'),
            'Key'    => $pending->object_key,
        ]);
    } catch (S3Exception) {
        return response()->json(['error' => 'Upload not found in storage'], 422);
    }

    abort_if($meta['ContentLength'] > 10 * 1024 * 1024, 422, 'File exceeds size limit.');

    // Move from pending/ to documents/
    $permanentKey = str_replace('pending/', 'documents/', $pending->object_key);
    // copyObject + deleteObject ...

    $pending->delete();
    return response()->json(['document_key' => $permanentKey]);
}

Security Tips

  • ▸Use short TTLs (10-15 minutes) on presigned URLs
  • ▸Validate content-type server-side, not just client-claimed
  • ▸Always verify object existence and size in Phase 3
  • ▸Clean up expired pending uploads with a scheduled command
  • ▸Restrict PutObject via S3 bucket policy to your key prefix only