Skip to content

RESOURCES / BLOG

How to Block Malware at the Point of Upload

Allowing users to upload files is the baseline expectation for social apps. Many backend teams do manual checks or trust that users are on their best behavior, but if a harmful file slips through and reaches production, chaos ensues: data loss, system damage, stolen accounts – all sorts of headaches you shouldn’t have to worry about.

You need a safe checkpoint the moment a file enters your system. Malware scanning at upload stops unsafe files before they spread.

Cloudinary’s Perception Point add-on helps you do this without new servers or complex logic. It scans files for known and unknown threats and returns a clear result. Your app can block unsafe files or hide them until the scan is complete.

This guide shows you how to build that flow with a simple backend and a clean UI that works fast.

You’ll build a fully automated upload system that checks every file for malware before it becomes usable.

Here’s what the system does:

  • You upload a file with the Cloudinary Upload Widget.
  • Cloudinary stores the file in your account.
  • The Perception Point add-on scans the file for malware.
  • Your backend listens for scan updates through a webhook.
  • The frontend polls a status endpoint until the scan is complete.
  • The UI shows the result, clean or blocked.

You don’t need to manage antivirus servers or complex queues. Cloudinary handles the scan and sends a simple status update.

You can test the full project live:

Cloudinary’s Perception Point add-on offers a built-in malware scanner that checks every uploaded file, images, videos, PDFs, ZIP files, and many more formats.

When a file is uploaded, Cloudinary creates a moderation record. The scan moves through three simple states:

  • pending: The scan is running.
  • approved: The file is clean.
  • rejected: The file is unsafe.

Cloudinary sends a webhook to your server whenever the scan starts and when it finishes. Each webhook includes the file’s public_id and its updated status. Your server can store it, use it, or return it to the UI.

You can read more about the add-on here:

https://cloudinary.com/documentation/perception_point_malware_detection_addon

This guide will show you how to receive those updates and display them in your app.

Let’s build a small app that lets users upload a file, then watch its malware scan status update in real time. The project has two parts, a static frontend served from the public folder, and a simple Express backend hosted on Vercel.

Grab the repo here:

Clone it and install the packages:

git clone https://github.com/musebe/secure-upload-app
cd secure-upload-app
npm install
Code language: PHP (php)

Create a .env file in the root of the project.

CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_key
CLOUDINARY_API_SECRET=your_secret

These values connect your server to Cloudinary so it can fetch the malware status for each file.

Run:

node server.js
Code language: CSS (css)

Then open:

http://localhost:3000

You should see the upload button and empty “Recent uploads” list.

The upload widget handles the full upload flow. It sends the file to Cloudinary, triggers the malware scan, and returns the public_id that you use to poll for the scan status.

Place this script at the top of your index.html file:

<script src="https://upload-widget.cloudinary.com/latest/global/all.js"></script>
Code language: HTML, XML (xml)

This loads the Cloudinary upload widget in the browser.

Add this HTML element:

<button id="upload_button">Upload a file</button>
Code language: HTML, XML (xml)

Inside app.js, set up the widget:

const widget = cloudinary.createUploadWidget(
  {
    cloudName: "your_cloud_name",
    uploadPreset: "secure_preset",
  },
  (error, result) => {
    if (result.event === "success") {
      addUploadToList(result.info.public_id);
      startStatusPolling(result.info.public_id);
    }
  }
);

document
  .getElementById("upload_button")
  .addEventListener("click", () => widget.open());
Code language: PHP (php)

The widget handles file selection, upload, and returns the upload result. When the upload is done, you save the public_id and start polling your backend.

Go to your Cloudinary Media Library. You should see each uploaded file inside the folder you set in the preset.

Right now, the widget uploads files to Cloudinary. To block malware at upload time, you need one extra step. Tell Cloudinary to send every file through the Perception Point malware scanner.

You do this with an upload preset.

In your Cloudinary account:

  1. Go to Settings > Add-ons.
  2. Find Perception Point Malware Detection.
  3. Activate it for your account.

You can read the full docs here: https://cloudinary.com/documentation/perception_point_malware_detection_addon

In Settings > Upload > Upload presets:

  1. Click Add upload preset.

  2. Set Name to something clear, for example:

    secure_malware_scan
    
  3. Under Upload control, set:

    • Unsigned to Enabled (for the widget).
  4. Under Moderation:

    • Choose Perception Point.
    • Make sure it is set to Auto so each upload triggers a scan.
  5. Optional but nice:

    • Set Folder to secure_malware_scan so all files live in one place.

Save the preset.

Now every upload that uses this preset will be scanned.

In your app.js, set the same preset name:

const uploadWidget = cloudinary.createUploadWidget(
  {
    cloudName: "your_cloud_name",
    uploadPreset: "secure_malware_scan",
    folder: "secure_malware_scan",
  },
  onUploadComplete
);
Code language: JavaScript (javascript)

You can see this wired up in the repo here:

From this point:

  • Every upload goes into secure_malware_scan.
  • Each file gets a moderation record.
  • The first status is pending.
  • Perception Point then decides approved or rejected.

The backend sits between Cloudinary and your UI and gives you safe, clean status data for each file.

You can view the full server code here:

The backend does three things:

  1. Exposes simple health and debug routes.
  2. Fetches malware status for a file from Cloudinary.
  3. Receives webhooks from Cloudinary when scans complete.

You use Express, CORS, dotenv, and the Cloudinary SDK.

const express = require("express");
const cors = require("cors");
const dotenv = require("dotenv");
const { v2: cloudinary } = require("cloudinary");

dotenv.config();

const app = express();

app.use(express.json());
app.use(cors());
Code language: PHP (php)

Cloudinary is configured with the same values you set in your .env:

cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
});

Code language: CSS (css)

The backend pulls moderation info from Cloudinary using the asset’s public_id. Your UI calls this route while the scan is running.

app.get("/api/status/:publicId", async (req, res) => {
  const publicId = req.params.publicId;

  try {
    const resource = await cloudinary.api.resource(publicId, {
      resource_type: "image",
    });

    const moderationArr = resource.moderation || [];
    const moderation = moderationArr.find((m) => m.kind === "perception_point");

    const status = moderation ? moderation.status : "none";

    res.json({
      status,
      kind: moderation ? moderation.kind : null,
    });
  } catch (err) {
    res.status(500).json({ error: "Failed to get status" });
  }
});
Code language: JavaScript (javascript)

The frontend does not need to know any Cloudinary details. It only cares about status, which will be pending, approved, or rejected.

Polling works, but webhooks give you the full story.

Cloudinary sends you two key events:

  • When the file is uploaded.
  • When the malware scan is done.

You receive both at the /api/cloudinary-webhook endpoint.

You can see the full handler here:

In your Cloudinary dashboard:

  1. Go to Settings → Webhooks.

  2. Add this URL in production:

    https://secure-upload-app.vercel.app/api/cloudinary-webhook
    

    In local dev you used an ngrok URL like:

    https://your-id.ngrok-free.app/api/cloudinary-webhook
    

Cloudinary now sends JSON payloads to your server after each upload and after each scan.

Docs for the payload shape are here: https://cloudinary.com/documentation/perception_point_malware_detection_addon

The handler in this project keeps things simple.

It just logs what came in and returns 200.

app.post("/api/cloudinary-webhook", (req, res) => {
  console.log("Incoming webhook:", req.body);
  res.status(200).json({ received: true });
});
Code language: JavaScript (javascript)

You’ll see two logs per file:

  • One with notification_type: "upload" and status: "pending".
  • One with notification_type: "moderation" and moderation_status: "approved" or "rejected".

You can later:

  • Store these in a database.
  • Trigger alerts when a file is rejected.
  • Sync other systems.

For this demo, polling is enough for the UI. The webhook is there to show how the scan behaves behind the scenes.

Once a file is uploaded, you know its public_id. Now the frontend needs to ask your backend, “Is this file safe yet?” That’s where /api/status/:publicId comes in.

You can see the full UI logic here:

And the live result here:

Each upload appears as a “card” in the Recent uploads section.

You’ll be able to track file name and size, Cloudinary public_id, and the current malware status.

A simplified version of the DOM creation:

function buildUploadItem(info, status) {
  const item = document.createElement("div");
  item.className = "upload-item";
  item.dataset.publicId = info.public_id;

  const chip = document.createElement("span");
  chip.className = "status-chip";

  item.appendChild(chip);
  updateStatusChip(item, status, info);

  return item;
}
Code language: JavaScript (javascript)

The status-chip content changes based on the scan state.

The status chip updates its text and class based on the status value:

function updateStatusChip(item, status, info) {
  const chip = item.querySelector(".status-chip");

  if (status === "approved") {
    chip.textContent = "Safe";
    chip.classList.add("status-safe");
  } else if (status === "rejected") {
    chip.textContent = "Blocked";
    chip.classList.add("status-blocked");
  } else {
    chip.textContent = "Pending scan";
    chip.classList.add("status-pending");
  }
}
Code language: JavaScript (javascript)

Your CSS handles the colors for .status-safe, .status-pending, and .status-blocked.

When Cloudinary reports event: "success" for an upload, you start polling /api/status/:publicId until the scan leaves the pending state.

Core polling logic:

function startStatusPolling(info, item) {
  const publicId = info.public_id;

  const poll = async () => {
    const res = await fetch(`/api/status/${encodeURIComponent(publicId)}`);

    if (!res.ok) {
      setTimeout(poll, 4000);
      return;
    }

    const data = await res.json();

    if (!data.status || data.status === "pending") {
      setTimeout(poll, 4000);
      return;
    }

    updateStatusChip(item, data.status, info);
  };

  poll();
}
Code language: JavaScript (javascript)

The important part is simple. While status is pending, keep retrying. Once you get approved or rejected, update the card and stop. This gives users clear feedback without a reload.

The upload flow starts with one element, the Upload a file button. This button triggers Cloudinary’s Upload Widget and hands every file to your preset, which includes the malware scanning add-on.

In public/index.html, all you need is:

<button id="upload_button" type="button" class="btn-primary">
  Upload a file
</button>
Code language: HTML, XML (xml)

This is the only element you interact with in JavaScript. Everything else is handled by the widget.

The widget is launched when the user clicks this button:

const uploadButton = document.getElementById("upload_button");

const widget = cloudinary.createUploadWidget(
  {
    cloudName: CLOUD_NAME,
    uploadPreset: "secure_malware_scan",
    sources: ["local", "camera", "url"],
  },
  (error, result) => {
    if (result.event === "success") {
      handleUpload(result.info);
    }
  }
);

uploadButton.addEventListener("click", () => {
  widget.open();
});
Code language: PHP (php)

This gives the user three options:

  • Upload from their device
  • Upload from camera
  • Upload from a URL

The widget handles file selection, previews, and upload progress.

When the upload finishes:

  • Cloudinary stores the asset.
  • The Perception Point add-on starts scanning.
  • The widget returns the full upload info to your handleUpload function.
  • You render the file to the page in a “pending scan” state.
  • Your polling loop starts checking /api/status/:publicId.

This is the start of the flow that updates the UI every few seconds until Cloudinary marks the file as approved or rejected.

You now have a working system that blocks malware at the point of upload. Cloudinary handles the file, the Perception Point add-on scans it, and your backend polls for updates until the file is marked safe or blocked. The frontend stays simple, the status updates in real time, and you get a full, secure workflow without building a scanner yourself.

This approach scales well. You can drop the upload widget into any app, attach the same preset, and immediately add malware protection. If you want to extend the setup, you can add queued processing, user notifications, or database logging. The core idea stays the same: scan everything at the moment it enters your system.

You can explore the full code here:

Start Using Cloudinary

Sign up for our free plan and start creating stunning visual experiences in minutes.

Sign Up for Free