ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

Ā© 2026 ZyVOP. Crafted with care for the developer community.

Made with ā¤ļø by the ZyVOP team
All systems operational
HomeFile Uploads Done Right: S3, Presigned URLs, and Validation That Actually Works

File Uploads Done Right: S3, Presigned URLs, and Validation That Actually Works

Why your server should never touch the file bytes — and the two upload patterns that cover every use case

#Node.js S3 file upload#AWS S3 presigned URL#multer S3 upload#direct upload S3 Node.js#S3 presigned URL tutorial 2026#file upload Express security#AWS SDK v3 Node.js
Z
ZyVOP

Senior Developer

May 25, 2026
8 min read
4 views
File Uploads Done Right: S3, Presigned URLs, and Validation That Actually Works

Most file upload tutorials show you how to receive a file on your server and then push it to S3. That approach works, but it has a problem: every uploaded file passes through your server, consuming memory, bandwidth, and time. Upload a 50MB video and your Node.js process is tied up streaming bytes it does not need to see.

The production pattern is different. Your server generates a presigned URL — a temporary, pre-authorized S3 link — and sends it to the client. The client uploads directly to S3. Your server never touches the file bytes. This scales to any file size without touching your infrastructure.

This guide covers both approaches: direct server-side uploads for simple cases, and presigned URL uploads for everything else.


Setup

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner multer sharp
// src/lib/s3.js
import { S3Client } from '@aws-sdk/client-s3';

export const s3 = new S3Client({
  region: process.env.AWS_REGION || 'us-east-1',
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
});

export const BUCKET = process.env.S3_BUCKET;

Approach 1 — Server-Side Upload (Simple Files, Under 10MB)

Use this for small files like avatars or documents where you want to process the file before storing it (resize, validate, virus scan).

// src/routes/upload.js
import multer from 'multer';
import sharp from 'sharp';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { s3, BUCKET } from '../lib/s3.js';
import { randomUUID } from 'crypto';
import path from 'path';

// Store files in memory — never on disk
const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 5 * 1024 * 1024,   // 5MB hard limit
    files: 1,                    // One file per request
  },
  fileFilter: (req, file, cb) => {
    // Whitelist allowed MIME types — never trust the extension alone
    const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
    if (allowed.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error(`File type ${file.mimetype} not allowed`), false);
    }
  },
});

router.post('/upload/avatar',
  authenticate,
  upload.single('avatar'),
  async (req, res) => {
    if (!req.file) {
      return res.status(400).json({ error: 'No file provided' });
    }

    try {
      // Process the image — resize and convert to WebP
      // This also strips EXIF data (GPS location, device info, etc.)
      const processed = await sharp(req.file.buffer)
        .resize(400, 400, {
          fit: 'cover',
          position: 'centre',
        })
        .webp({ quality: 85 })
        .toBuffer();

      // Generate a non-guessable key — never use the original filename
      const key = `avatars/${req.user.id}/${randomUUID()}.webp`;

      await s3.send(new PutObjectCommand({
        Bucket: BUCKET,
        Key: key,
        Body: processed,
        ContentType: 'image/webp',
        // Cache for 1 year — content-addressed by UUID so cache is always valid
        CacheControl: 'public, max-age=31536000, immutable',
        // Prevent the browser from sniffing content type
        Metadata: {
          uploadedBy: req.user.id,
          originalName: req.file.originalname.slice(0, 100),
        },
      }));

      const url = `https://${BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;

      // Store reference in database
      await db.query(
        'UPDATE users SET avatar_url = $1 WHERE id = $2',
        [url, req.user.id]
      );

      res.json({ url, key });
    } catch (err) {
      req.log?.error({ error: err.message }, 'Avatar upload failed');
      res.status(500).json({ error: 'Upload failed' });
    }
  }
);

Important: Never use the original filename as the S3 key. User-supplied filenames can contain path traversal characters (../../etc/passwd), extremely long strings, or overwrite existing files. Always generate your own key with a UUID.


Approach 2 — Presigned URL Upload (Large Files, Direct to S3)

For anything above 10MB — videos, large PDFs, raw files — route the upload directly from the client to S3. Your server only generates a temporary signed URL.

// src/routes/upload.js
import { PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3, BUCKET } from '../lib/s3.js';
import { randomUUID } from 'crypto';

// Step 1 — Client requests a presigned upload URL
router.post('/upload/presign', authenticate, async (req, res) => {
  const { filename, contentType, fileSize } = req.body;

  // Validate on the server before generating the URL
  const ALLOWED_TYPES = {
    'image/jpeg': '.jpg',
    'image/png': '.png',
    'image/webp': '.webp',
    'video/mp4': '.mp4',
    'application/pdf': '.pdf',
  };

  if (!ALLOWED_TYPES[contentType]) {
    return res.status(400).json({ error: 'File type not allowed' });
  }

  const MAX_SIZE = 500 * 1024 * 1024;  // 500MB
  if (fileSize > MAX_SIZE) {
    return res.status(400).json({ error: 'File too large' });
  }

  const ext = ALLOWED_TYPES[contentType];
  const key = `uploads/${req.user.id}/${randomUUID()}${ext}`;

  // Generate a presigned URL valid for 15 minutes
  const command = new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    ContentType: contentType,
    ContentLength: fileSize,           // S3 enforces this — prevents size switching
    Metadata: {
      userId: req.user.id,
      originalFilename: filename?.slice(0, 100) || 'unknown',
    },
  });

  const uploadUrl = await getSignedUrl(s3, command, {
    expiresIn: 15 * 60,  // 15 minutes
  });

  // Store a pending record — confirmed after client signals completion
  const { rows } = await db.query(`
    INSERT INTO uploads (user_id, s3_key, content_type, file_size, status)
    VALUES ($1, $2, $3, $4, 'pending')
    RETURNING id
  `, [req.user.id, key, contentType, fileSize]);

  res.json({
    uploadUrl,
    uploadId: rows[0].id,
    key,
    expiresIn: 15 * 60,
  });
});

// Step 2 — Client calls this after the upload completes
router.post('/upload/confirm/:uploadId', authenticate, async (req, res) => {
  const { uploadId } = req.params;

  const upload = await db.query(
    'SELECT * FROM uploads WHERE id = $1 AND user_id = $2 AND status = $3',
    [uploadId, req.user.id, 'pending']
  );

  if (!upload.rows[0]) {
    return res.status(404).json({ error: 'Upload not found' });
  }

  // Verify the file actually made it to S3
  try {
    await s3.send(new GetObjectCommand({
      Bucket: BUCKET,
      Key: upload.rows[0].s3_key,
    }));
  } catch (err) {
    return res.status(400).json({ error: 'File not found in storage — upload may have failed' });
  }

  await db.query(
    'UPDATE uploads SET status = $1, confirmed_at = NOW() WHERE id = $2',
    ['confirmed', uploadId]
  );

  const fileUrl = `https://${BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${upload.rows[0].s3_key}`;

  res.json({ url: fileUrl, key: upload.rows[0].s3_key });
});

On the frontend:

// Client-side upload flow
async function uploadFile(file) {
  // Step 1: Get presigned URL
  const { uploadUrl, uploadId } = await fetch('/upload/presign', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type,
      fileSize: file.size,
    }),
  }).then(r => r.json());

  // Step 2: Upload directly to S3 (no auth header — URL is already signed)
  const uploadResponse = await fetch(uploadUrl, {
    method: 'PUT',
    body: file,
    headers: {
      'Content-Type': file.type,
    },
  });

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

  // Step 3: Confirm with your server
  const { url } = await fetch(`/upload/confirm/${uploadId}`, {
    method: 'POST',
  }).then(r => r.json());

  return url;
}

Generating Presigned Download URLs (Private Files)

If your files should not be publicly accessible — user documents, invoices, private media — do not set a public S3 policy. Generate presigned download URLs on demand.

router.get('/files/:uploadId/download', authenticate, async (req, res) => {
  const upload = await db.query(
    'SELECT * FROM uploads WHERE id = $1 AND user_id = $2 AND status = $3',
    [req.params.uploadId, req.user.id, 'confirmed']
  );

  if (!upload.rows[0]) {
    return res.status(404).json({ error: 'File not found' });
  }

  const command = new GetObjectCommand({
    Bucket: BUCKET,
    Key: upload.rows[0].s3_key,
    ResponseContentDisposition: `attachment; filename="${upload.rows[0].original_filename}"`,
  });

  // URL valid for 5 minutes — short-lived for private files
  const downloadUrl = await getSignedUrl(s3, command, { expiresIn: 300 });

  res.json({ downloadUrl, expiresIn: 300 });
});

Deleting Files

Always delete from both S3 and your database. Orphaned S3 objects cost money and are a data retention liability.

router.delete('/files/:uploadId', authenticate, async (req, res) => {
  const upload = await db.query(
    'SELECT * FROM uploads WHERE id = $1 AND user_id = $2',
    [req.params.uploadId, req.user.id]
  );

  if (!upload.rows[0]) {
    return res.status(404).json({ error: 'File not found' });
  }

  // Delete from S3 first
  await s3.send(new DeleteObjectCommand({
    Bucket: BUCKET,
    Key: upload.rows[0].s3_key,
  }));

  // Then delete the DB record
  await db.query('DELETE FROM uploads WHERE id = $1', [req.params.uploadId]);

  res.json({ deleted: true });
});

S3 Bucket Policy (Least Privilege)

Your application's IAM user should only have the permissions it actually needs — never S3FullAccess.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::your-bucket-name/*"
    },
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::your-bucket-name"
    }
  ]
}

Enable S3 versioning on buckets that hold important user data — it lets you recover from accidental deletes. Enable server-side encryption (SSE-S3 or SSE-KMS) — it is free and takes one checkbox.


The Database Schema

CREATE TABLE uploads (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id          UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  s3_key           TEXT NOT NULL UNIQUE,
  content_type     TEXT NOT NULL,
  file_size        BIGINT,
  original_filename TEXT,
  status           TEXT NOT NULL DEFAULT 'pending'
                   CHECK (status IN ('pending', 'confirmed', 'deleted')),
  created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  confirmed_at     TIMESTAMPTZ
);

CREATE INDEX idx_uploads_user_id ON uploads(user_id);
CREATE INDEX idx_uploads_status ON uploads(status);

-- Clean up pending uploads older than 1 hour (presigned URL expired)
-- Add to a cron job:
-- DELETE FROM uploads WHERE status = 'pending' AND created_at < NOW() - INTERVAL '1 hour';

Security Checklist

āœ… Never use the original filename as S3 key
āœ… Validate MIME type server-side, not just extension
āœ… Set ContentLength in presigned URL to prevent size switching
āœ… Private files served via presigned download URLs, not public S3 URLs
āœ… IAM user has least-privilege S3 policy
āœ… S3 versioning enabled on important buckets
āœ… Server-side encryption enabled
āœ… Multer limits set: fileSize and files count
āœ… sharp strips EXIF data on image processing
āœ… Pending uploads cleaned up by cron job
Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design