Skip to content
ExponaBLOG
AWS FOR DEVELOPERS · DAY 033 / 100

Uploads with presigned URLs: AWS lessons from production

Let browsers upload straight to S3.

Expona Admin9 min read

Uploads with presigned URLs

One of the trickiest parts of building scalable upload flows is deciding where the bandwidth cost should live. Do you proxy every file through your API, or do you let the browser talk to S3 directly? Uploads with presigned URLs give you the best of both worlds: your server stays lightweight by issuing short-lived, cryptographically signed URLs that grant temporary upload permission, while the browser handles the actual file transfer directly to S3.

The case for presigned URLs

When I was building an e-commerce platform, we initially routed all product images through our API. Within weeks, our backend was drowning in bandwidth costs and request latency was climbing. The issue was simple: every 5 MB image upload consumed server resources and network I/O that could have been handled by S3 directly.

Presigned URLs solved this elegantly. Instead of uploading to your API, the browser requests a temporary URL from your backend. That URL includes an AWS signature that proves you approved it, and it expires after a few minutes. The browser then uploads directly to S3 using that URL, bypassing your servers entirely. This means your API only handles the orchestration (generating the URL, confirming the upload), not the heavy lifting.

The flow looks like this:

  1. Browser requests an upload URL from your API with metadata (filename, content type, size).
  2. API validates the request, then calls AWS to generate a presigned URL.
  3. API returns the signed URL to the browser.
  4. Browser uploads the file directly to S3 using that URL.
  5. S3 accepts or rejects based on the signature and constraints.
  6. Browser notifies your API that the upload is complete, so you can save metadata to your database.

API signs a short-lived URL

The presigned URL is generated by your backend using the AWS SDK. Here's what that looks like in Node.js with the S3 client:

bash
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Your API endpoint receives a request with basic upload details:

javascript
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({ region: 'us-east-1' });

export async function generateUploadUrl(req, res) {
  const { filename, contentType } = req.body;
  
  // Validate the requester (auth check)
  if (!req.user) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // Generate a unique key scoped to the user
  const key = `uploads/${req.user.id}/${Date.now()}-${filename}`;

  try {
    const url = await getSignedUrl(
      s3,
      new PutObjectCommand({
        Bucket: 'my-uploads-bucket',
        Key: key,
        ContentType: contentType,
      }),
      { expiresIn: 300 } // 5 minutes
    );

    res.json({ url, key });
  } catch (error) {
    res.status(500).json({ error: 'Failed to generate upload URL' });
  }
}

The expiresIn: 300 means the URL is only valid for 5 minutes. After that, S3 rejects any upload attempt using it. This limits exposure if the URL leaks, and it forces the browser to request a fresh URL if the upload takes longer (which should prompt a retry).

Browser uploads directly — no server bandwidth

Once the frontend has the presigned URL, the upload is straightforward. Here's a React example:

javascript
async function uploadFile(file) {
  // Step 1: Get the presigned URL from your API
  const response = await fetch('/api/generate-upload-url', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type,
    }),
  });

  if (!response.ok) {
    throw new Error('Failed to get upload URL');
  }

  const { url, key } = await response.json();

  // Step 2: Upload directly to S3 using the presigned URL
  const uploadResponse = await fetch(url, {
    method: 'PUT',
    headers: { 'Content-Type': file.type },
    body: file,
  });

  if (!uploadResponse.ok) {
    throw new Error('Upload to S3 failed');
  }

  // Step 3: Notify your API that the upload succeeded
  await fetch('/api/confirm-upload', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key }),
  });
}

The key insight here is that your API never touches the file itself. The 50 MB video or 10 MB product image goes straight from the browser to S3. Your server only handles tiny JSON messages, which is orders of magnitude cheaper in terms of bandwidth and compute.

Restrict content type and size

A presigned URL by default trusts whoever has it. To add guardrails, you include constraints in the signature. When the browser tries to upload, S3 verifies the constraints. If the file doesn't match, the upload is rejected.

Here's an example with content-type restriction:

javascript
export async function generateImageUploadUrl(req, res) {
  const { filename } = req.body;
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];

  // This is just for the frontend as a UX boundary
  if (!allowedTypes.includes(req.body.contentType)) {
    return res.status(400).json({ error: 'Invalid image type' });
  }

  const key = `uploads/${req.user.id}/${Date.now()}-${filename}`;

  try {
    const url = await getSignedUrl(
      s3,
      new PutObjectCommand({
        Bucket: 'my-uploads-bucket',
        Key: key,
        ContentType: req.body.contentType,
      }),
      { expiresIn: 300 }
    );

    res.json({ url, key });
  } catch (error) {
    res.status(500).json({ error: 'Failed to generate upload URL' });
  }
}

S3 also supports content-length-range policies to enforce size limits. However, the SDK's getSignedUrl doesn't directly expose this. You'd need to build a more complex policy document. For most cases, enforcing size limits on the frontend and then validating after the upload completes is simpler and more maintainable.

In your frontend, validate before uploading:

javascript
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB

function validateFile(file) {
  if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
    throw new Error('Only JPEG, PNG, and WebP are allowed');
  }
  if (file.size > MAX_SIZE) {
    throw new Error('File must be smaller than 10 MB');
  }
}

Confirm upload before saving metadata

After S3 accepts the upload, you need to record it in your database. This is the final step and where you verify that the file actually exists before linking it to a user, product, or any entity.

Create an endpoint that confirms the upload:

javascript
export async function confirmUpload(req, res) {
  const { key } = req.body;

  if (!req.user) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // Verify the key belongs to this user
  const expectedKeyPrefix = `uploads/${req.user.id}/`;
  if (!key.startsWith(expectedKeyPrefix)) {
    return res.status(403).json({ error: 'Unauthorized key' });
  }

  try {
    // Check that the file actually exists in S3
    const headResponse = await s3.send(
      new HeadObjectCommand({
        Bucket: 'my-uploads-bucket',
        Key: key,
      })
    );

    // Save metadata to your database
    const upload = await db.uploads.create({
      userId: req.user.id,
      s3Key: key,
      fileName: key.split('/').pop(),
      size: headResponse.ContentLength,
      contentType: headResponse.ContentType,
      uploadedAt: new Date(),
    });

    res.json({ success: true, uploadId: upload.id });
  } catch (error) {
    if (error.name === 'NoSuchKey') {
      return res.status(404).json({ error: 'File not found in S3' });
    }
    res.status(500).json({ error: 'Failed to confirm upload' });
  }
}

The HeadObjectCommand checks that the file exists and retrieves its metadata without downloading the full object. This is a cheap verification step. Only after you confirm the file exists do you save a database record.

On the frontend, call this endpoint after the S3 upload completes:

javascript
async function uploadFile(file) {
  validateFile(file);

  const { url, key } = await (
    await fetch('/api/generate-upload-url', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        filename: file.name,
        contentType: file.type,
      }),
    })
  ).json();

  // Upload to S3
  const uploadResponse = await fetch(url, {
    method: 'PUT',
    headers: { 'Content-Type': file.type },
    body: file,
  });

  if (!uploadResponse.ok) {
    throw new Error('Upload failed');
  }

  // Confirm with backend
  const confirmResponse = await fetch('/api/confirm-upload', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key }),
  });

  if (!confirmResponse.ok) {
    throw new Error('Failed to confirm upload');
  }

  const { uploadId } = await confirmResponse.json();
  return uploadId;
}

Common mistakes

Not validating the key on confirm. If you don't check that the key belongs to the requesting user, a malicious actor could confirm uploads they didn't perform. Always scope keys by user and verify ownership before saving metadata.

Setting expiration too short. If your typical upload takes 2 minutes on slow networks, a 5-minute expiration is fine. But if you're dealing with large files or unreliable connections, give yourself more buffer. I've seen issues with 1-minute expirations on mobile uploads. 15–30 minutes is a safer range for most scenarios.

Forgetting the confirmation step. It's tempting to skip confirming the upload and just have the frontend notify your backend that it's done. But if the upload actually failed for some reason (network interruption, S3 rejected it), you'll have orphaned database records pointing to files that don't exist. Always verify the file in S3 before saving metadata.

Storing sensitive URLs in logs. Presigned URLs contain AWS signatures. If you log them or pass them through systems that log, you're potentially leaking temporary credentials. Be mindful of what gets logged in production.

Ignoring CORS configuration. If your frontend is on a different domain than S3, you need to configure CORS on the bucket. Without it, the browser's same-origin policy blocks the upload. Add a CORS policy to your bucket:

javascript
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT"],
    "AllowedOrigins": ["https://yourdomain.com"],
    "ExposeHeaders": [],
    "MaxAgeSeconds": 3000
  }
]

Key takeaways

  • Presigned URLs let your backend authorize uploads without handling the data transfer, saving bandwidth and server load.
  • Use the S3 SDK's getSignedUrl with PutObjectCommand to generate short-lived URLs that expire in minutes.
  • Validate content type and size on the frontend, and enforce them in your backend logic.
  • Always confirm that the file exists in S3 before saving metadata to your database.
  • Scope S3 keys by user and verify ownership when confirming uploads to prevent data leaks.
  • Set expiration times conservatively—15–30 minutes covers most real-world cases without leaving URLs valid too long.
  • Configure CORS on your S3 bucket if uploads come from a different domain.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles