Venkatesh♡
All blogs
Oct 07, 202615 min read

How to Upload Images to Amazon S3 and Serve Them with CloudFront Signed URLs in Node.js

A step-by-step guide to uploading, deleting and securely delivering images with Amazon S3, CloudFront signed URLs, Multer and Sharp in a Node.js and Express backend.

What you'll use

Amazon S3CloudFrontIAMSigned URLsNode.jsMulterSharp
01

What we're building

In this guide you will store images in a private Amazon S3 bucket and deliver them through the CloudFront CDN. The bucket is never exposed publicly. Users receive short-lived signed URLs instead.

  • PutObject uploads an image to S3.
  • DeleteObject removes an image from S3.
  • CloudFront serves images from edge locations close to the user, which improves delivery speed.
  • Signed URLs grant temporary access and let you control how long each link stays valid.
architecture
  Upload                         Delivery
  ──────                         ────────
  Client → Express API           Client → CloudFront (signed URL)
            │                                │
            ▼                                ▼
   multer → sharp (WebP)           Edge cache (HIT / MISS)
            │                                │
            ▼                                ▼
   S3 PutObject  ───────────────►  Private S3 bucket (via OAC)
IMPORTANTUse an IAM user for everything below, not the AWS root account.
02

Prerequisites and packages

You need an AWS account, Node.js 18+, and an Express project. Install the dependencies:

bash
npm install @aws-sdk/client-s3 @aws-sdk/cloudfront-signer multer sharp

By the end you will have these environment variables. The next steps show where each value comes from:

.env
AWS_REGION="ap-south-2"
AWS_BUCKET_NAME="my-project-images"
AWS_ACCESS_KEY_ID="AKIAXXXXXXXXXXXX"
AWS_SECRET_ACCESS_KEY="xxxxxxxxxxxxxxxxxxxxxxxx"
CLOUDFRONT_DOMAIN="https://xxxxxxxx.cloudfront.net"
AWS_CLOUDFRONT_KEY_PAIR_ID="E123456789ABCDE"
CLOUDFRONT_PRIVATE_KEY_PATH="./keys/private_key.pem"
03

Choose a region and create the S3 bucket

Pick the AWS region closest to your users or servers. For example, Asia Pacific (Hyderabad) is ap-south-2, which becomes AWS_REGION.

Open AWS Console → S3 → General purpose buckets → Create bucket, then:

  1. Enter a globally unique bucket name, for example my-project-images.
  2. Keep Block all public access enabled. CloudFront will read the bucket, not the public.
  3. Click Create bucket.

The bucket name becomes AWS_BUCKET_NAME.

04

Create IAM credentials

Your backend needs AWS credentials to upload and delete objects. Choose the option that matches your setup.

A dedicated user limits the damage if a key ever leaks. Go to IAM → Users → Create user and name it, for example, my-project-s3-user.

Attach an inline policy that grants only what the app needs, scoped to your bucket:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::my-project-images/*"
    }
  ]
}
TIPAdd s3:GetObject only if your backend must read objects directly from S3. CloudFront reads through its own access rule.

Create the access key

Open IAM → Users → your user → Security credentials → Create access key. AWS shows the Access key ID and Secret access key. Add both to .env.

IMPORTANTCopy the secret access key immediately. AWS shows it only once. If you lose it, you must create a new key.
IMPORTANTNever commit the secret to GitHub, hard-code it, or expose it in frontend JavaScript. Use environment variables or a secrets manager. On EC2, an IAM role is even better than long-lived keys.
05

Create the CloudFront distribution

Go to CloudFront → Distributions → Create distribution and configure:

  1. Choose a plan, for example Pay as you go.
  2. Give it a name such as project-images-cdn.
  3. Set the origin type to Amazon S3 and click Browse S3 to select your bucket.
  4. Enable Allow private S3 bucket access to CloudFront (Recommended). This uses Origin Access Control (OAC).
  5. Keep the remaining defaults and create the distribution.

Copy the Distribution domain name, for example https://xxxxxxxx.cloudfront.net. It becomes CLOUDFRONT_DOMAIN.

TIPIf CloudFront asks to update the bucket policy, accept it. This policy is what lets CloudFront read your private bucket. Without it you will get 403 errors.
06

Generate the public and private keys

Signed URLs use an RSA key pair. Your backend signs URLs with the private key and CloudFront verifies them with the public key. In Git Bash or any terminal:

bash
mkdir keys

# Generate the private key
openssl genrsa -out keys/private_key.pem 2048

# Derive the public key from it
openssl rsa -pubout -in keys/private_key.pem -out keys/public_key.pem

Keep private_key.pem on your server only. You will upload public_key.pem to AWS in the next step.

.gitignore
# .gitignore
keys/
*.pem
IMPORTANTNever commit the private key. Anyone who has it can generate valid URLs for your content.
07

Add the public key and create a key group

Upload the public key

Go to CloudFront → Public keys → Create public key. Name it my-project-cloudfront-key, paste the contents of public_key.pem, and create it. CloudFront returns a Public key ID, for example E123456789ABCDE. This is your AWS_CLOUDFRONT_KEY_PAIR_ID.

Create the key group

Go to CloudFront → Key groups → Create key group, name it my-project-key-group, and add the public key.

how it fits
Private key  →  Backend (signs URLs)
Public key   →  CloudFront (verifies URLs)
Key group    →  Distribution behavior (who may sign)
08

Require signed URLs on the distribution

Creating a key group does nothing until the distribution requires it.

  1. Open your distribution and go to the Behaviors tab.
  2. Select the default behavior and click Edit.
  3. Under Restrict viewer access, choose Yes.
  4. Set trusted authorization type to Trusted key groups and select my-project-key-group.
  5. Save changes and wait for the distribution to finish deploying.
TIPNow a plain CloudFront URL returns 403 Forbidden. Only URLs signed by your backend work.
09

The S3 utility module

Create utils/s3_bucket/index.js. It handles unique file names, uploads, deletes and signed URL generation.

javascript
const {
  S3Client,
  PutObjectCommand,
  DeleteObjectCommand,
} = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/cloudfront-signer");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");

const {
  AWS_REGION,
  AWS_BUCKET_NAME,
  AWS_ACCESS_KEY_ID,
  AWS_SECRET_ACCESS_KEY,
  AWS_CLOUDFRONT_KEY_PAIR_ID,
  CLOUDFRONT_DOMAIN,
  CLOUDFRONT_PRIVATE_KEY_PATH,
} = process.env;

const s3 = new S3Client({
  region: AWS_REGION,
  credentials: {
    accessKeyId: AWS_ACCESS_KEY_ID,
    secretAccessKey: AWS_SECRET_ACCESS_KEY,
  },
});

// Read the private key once at startup, not on every request
const privateKey = fs.readFileSync(
  path.resolve(CLOUDFRONT_PRIVATE_KEY_PATH),
  "utf8"
);

const generateUniqueFileName = (userId, originalFileName) => {
  const extension = path.extname(originalFileName || "").toLowerCase();
  const now = Date.now();
  const hash = crypto
    .createHash("sha256")
    .update(`${userId}-${now}-${crypto.randomUUID()}`)
    .digest("hex")
    .substring(0, 16);

  return `${userId}_${now}_${hash}${extension}`;
};

const uploadToS3 = async (file, folder, fileName, mimetype = "image/webp") => {
  const key = `${folder}/${fileName}`;

  await s3.send(
    new PutObjectCommand({
      Bucket: AWS_BUCKET_NAME,
      Key: key,
      Body: file,
      ContentType: mimetype,
      ContentDisposition: "inline",
      CacheControl: "public, max-age=31536000, immutable",
    })
  );

  return key; // store this key in the database
};

const deleteFile = async (fileKey) =>
  s3.send(
    new DeleteObjectCommand({
      Bucket: AWS_BUCKET_NAME,
      Key: fileKey,
    })
  );

// Returns a temporary signed CloudFront URL
const getImageUrl = (fileKey, validForHours = 24) => {
  if (!fileKey) return null;

  return getSignedUrl({
    url: `${CLOUDFRONT_DOMAIN}/${fileKey}`,
    keyPairId: AWS_CLOUDFRONT_KEY_PAIR_ID,
    privateKey,
    dateLessThan: new Date(Date.now() + validForHours * 60 * 60 * 1000),
  });
};

module.exports = { uploadToS3, deleteFile, getImageUrl, generateUniqueFileName };
TIPS3 stores the file key, such as profile/12_1760000000000_ab12cd34.webp. Store the key in your database, never the signed URL. Signed URLs expire and are generated on demand.
10

Define S3 folders

Folder constants keep keys consistent across your codebase:

javascript
const S3_FOLDERS = {
  PROFILE: "profile",
  POST: "post",
};

module.exports = S3_FOLDERS;
11

Upload middleware with Multer

Multer parses multipart/form-data. Memory storage keeps the file in a buffer so we can process it before sending it to S3. This middleware accepts JPEG, PNG, WEBP and PDF files up to 5 MB.

javascript
const multer = require("multer");

const ALLOWED = ["image/jpeg", "image/png", "image/webp", "application/pdf"];

const fileFilter = (req, file, cb) => {
  if (ALLOWED.includes(file.mimetype)) return cb(null, true);
  cb(new Error("Only JPEG, PNG, WEBP images or PDF files are allowed"), false);
};

module.exports = multer({
  storage: multer.memoryStorage(),
  fileFilter,
  limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
});
12

Optimize images with Sharp

Sharp converts every uploaded image to WebP at 80% quality, which usually cuts file size substantially. PDFs pass through unchanged. The middleware supports upload.single(), upload.array() and upload.fields().

javascript
const sharp = require("sharp");
const path = require("path");

const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];
const randomName = () => `${Date.now()}-${Math.round(Math.random() * 1e9)}`;

const collectFiles = (req) => {
  if (req.file) return [req.file]; // upload.single()
  if (Array.isArray(req.files)) return req.files; // upload.array()
  if (req.files && typeof req.files === "object") {
    return Object.values(req.files).flat(); // upload.fields()
  }
  return [];
};

const processImages = async (req, res, next) => {
  try {
    const files = collectFiles(req);
    if (!files.length) return next();

    req.processedImages = await Promise.all(
      files.map(async (file) => {
        if (IMAGE_TYPES.includes(file.mimetype)) {
          const buffer = await sharp(file.buffer)
            .rotate() // respect EXIF orientation
            .webp({ quality: 80 })
            .toBuffer();

          return {
            fieldname: file.fieldname,
            filename: `${randomName()}.webp`,
            file: buffer,
            mimetype: "image/webp",
          };
        }

        // PDFs and other allowed non-image files pass through untouched
        return {
          fieldname: file.fieldname,
          filename: `${randomName()}${path.extname(file.originalname)}`,
          file: file.buffer,
          mimetype: file.mimetype,
        };
      })
    );

    next();
  } catch (error) {
    next(error);
  }
};

module.exports = processImages;
13

Routes and controller (user profile image)

As an example, assume your User model has a string column profileImage. The frontend sends multipart/form-data with the field image.

javascript
const express = require("express");
const router = express.Router();

const upload = require("../middleware/upload");
const processImages = require("../middleware/processImages");
const { uploadProfileImage, getUser } = require("../controllers/userController");

// Frontend sends multipart/form-data with the field name "image"
router.put("/profile-image", upload.single("image"), processImages, uploadProfileImage);
router.get("/:id", getUser);

// For multiple files use: upload.array("images", 10)

module.exports = router;
javascript
const User = require("../models/User");
const S3_FOLDERS = require("../constants/s3BucketFolders");
const {
  uploadToS3,
  deleteFile,
  generateUniqueFileName,
  getImageUrl,
} = require("../utils/s3_bucket");

// PUT /users/profile-image
const uploadProfileImage = async (req, res) => {
  try {
    const userId = req.user.id;
    const image = req.processedImages?.[0];

    if (!image) {
      return res.status(400).json({ message: "Profile image is required" });
    }

    const user = await User.findByPk(userId);
    if (!user) return res.status(404).json({ message: "User not found" });

    const oldKey = user.profileImage;

    const fileName = generateUniqueFileName(userId, image.filename);
    const key = await uploadToS3(
      image.file,
      S3_FOLDERS.PROFILE,
      fileName,
      image.mimetype
    );

    // Save the S3 key (not the URL) in the database
    await user.update({ profileImage: key });

    // Delete the old image only after the new one is safely stored
    if (oldKey) await deleteFile(oldKey).catch(console.error);

    return res.status(200).json({
      message: "Profile image uploaded successfully",
      data: { profileImage: getImageUrl(key) },
    });
  } catch (error) {
    console.error("Upload profile image error:", error);
    return res.status(500).json({ message: "Failed to upload profile image" });
  }
};

// GET /users/:id
const getUser = async (req, res) => {
  try {
    const user = await User.findByPk(req.params.id);
    if (!user) return res.status(404).json({ message: "User not found" });

    return res.status(200).json({
      data: {
        ...user.toJSON(),
        profileImage: getImageUrl(user.profileImage),
      },
    });
  } catch (error) {
    console.error("Get user error:", error);
    return res.status(500).json({ message: "Failed to get user" });
  }
};

module.exports = { uploadProfileImage, getUser };
TIPThe controller uploads the new image first and deletes the old one afterwards. If the upload fails, the user keeps their existing image.
14

Test it and verify CloudFront caching

Call the endpoint with a client such as Postman or curl:

bash
curl -X PUT http://localhost:3000/users/profile-image \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -F "image=@./photo.jpg"

Open the returned signed URL in your browser. Then open DevTools, go to the Network tab, select the image request, and check the response headers:

http
X-Cache: Miss from cloudfront   # first request, fetched from S3
X-Cache: Hit from cloudfront    # later requests, served from the edge

A Hit means CloudFront served the image from its edge cache. Requests for an unsigned URL should return 403.

15

Best practices and troubleshooting

Best practices

  • Store S3 keys in the database and generate signed URLs when responding.
  • Use short expiry times (hours or days) for sensitive images.
  • Use unique, hashed file names so immutable caching is safe.
  • Rotate access keys and key pairs periodically.
  • Prefer IAM roles over access keys when running on AWS.

Common errors

  • 403 Forbidden on every URL: the bucket policy for OAC is missing, or the wrong key ID is set.
  • 403 only on signed URLs: AWS_CLOUDFRONT_KEY_PAIR_ID must be the public key ID, not your IAM access key.
  • AccessDenied on upload: the IAM policy lacks s3:PutObject for that bucket.
  • Image not updating: use a new file name for each upload rather than overwriting the same key.
  • Signed URL always a cache Miss: query strings are part of the signature. Keep the CloudFront cache policy set to ignore the signing parameters.

Setup checklist

  1. Create a private S3 bucket.
  2. Create an IAM user with a least-privilege policy and an access key.
  3. Create a CloudFront distribution with OAC.
  4. Generate an RSA key pair and upload the public key.
  5. Create a key group and require it on the behavior.
  6. Add all values to .env.
  7. Add the S3 utility, Multer and Sharp middleware.
  8. Test the upload and check X-Cache.

Related blog

How Uber and Rapido Find Nearby Drivers in Real Time

Read next

Scrolled Too Far

Have an Idea? Let’s Turn It Into Something Real.