Scan Uploaded Files for Viruses in Next.js

Posted on
Scan Uploaded Files for Viruses in Next.js Filestack

To scan uploaded files for viruses in Next.js, attach a virus_detection workflow to each upload, keep the file pending, verify the signed webhook in a Route Handler, and only redirect to the file once it’s marked clean.

Tested on 5 October 2026 with Next.js 16.3.8 and filestack-js 4.3.9.

Your users upload files, and other users download them. That second step is where an infected PDF does its damage, so you want to scan uploaded files for viruses before anyone else can open them. In most Next.js apps nothing stands between the two:

const file = await client.upload(input);
await saveAttachment({ handle: file.handle }); // other users can open it from here

The upload resolves, the handle goes into the database, and the file is one link away from everyone who can see the record. Nothing has looked inside it. Closing that gap is part of secure file upload: the file is pending until a scan says it’s clean, and nothing hands its URL to another user before then.

Key takeaways

  • The upload resolves once the file is stored. The scan verdict follows on a webhook, about ten seconds later for the PDF and EICAR test files used here. Until then, treat the file as pending.
  • The scan is a virus_detection task in a workflow you attach to the upload.
  • The webhook handler checks FS-Signature against the raw body from request.text(), using the webhook secret.
  • A download route checks the verdict before it redirects to the file.
  • Infected files are removed with a signed remove call, and their CDN URL then returns 404.

Why the upload finishes before the scan does

A scan reads the file where it’s stored, so it starts once storage is done. The upload response tells you a scan is running and nothing else. file.workflows holds one entry per workflow, keyed by workflow ID:

{
  "28072b5a-f52a-4959-a3c8-69d79238f2ab": {
    "jobid": "becf1eea-c1d1-4737-91e3-29b7e00632f2"
  }
}

The verdict is posted to your webhook when the job finishes. So your app records the file as pending, with its job ID, and lets the webhook move it to clean or removed.

Sequence diagram: Next.js upload, Filestack virus scan, signed webhook, and a download route that serves only clean files
Sequence diagram of a Next.js upload with a virus scan. The browser gets a policy from the app, uploads to Filestack with the workflow ID, saves the handle as pending and polls. Filestack runs virus_scan and posts a signed fs.workflow webhook to the app, which marks the file clean or removes it. Another user’s download goes through /api/files, which redirects only when the file is clean

 

Three ways to scan uploads in a Next.js app

Option What you run Where it fits
ClamAV on your own server The clamd daemon, signature updates with freshclam, and a machine sized for the signature database You already run long-lived servers and want the engine in-house
Your storage provider’s scanning, such as Amazon GuardDuty Malware Protection for S3 or Microsoft Defender for Storage A cloud service that scans objects in your bucket and reports through object tags and events (EventBridge on AWS, Event Grid on Azure) Files already land in your own bucket on that cloud
A virus_detection task in a workflow Nothing. The scan runs where the file is stored, and the verdict is posted to your webhook Uploads already go through Filestack, or you’re adding them

On Vercel and similar hosts, a Route Handler runs as a short-lived function. There’s no long-running process to keep a signature database loaded, which makes the first row hard to run there. The rest of this article builds the third row. The virus detection for uploaded files page lists the file types it covers.

Create the scanning workflow

In the Developer Portal, open your application, then Workflows, then Create New.

  • Name the workflow and select the plus button to add a task.
  • On the Intelligence tab, choose virus_detection.
  • Set Task name to virus_scan and save the task.
  • Select Save Workflow and copy the Workflow Id.

The task name is the key the verdict arrives under, so the code below reads results.virus_scan. Start plan includes one workflow, so on Start add the scan as the first task of the workflow your app already runs.

The Developer Portal workflow editor with a single Virus Detection task, showing the workflow name, TTL and Workflow Id fields
The Developer Portal workflow editor with a single Virus Detection task, showing the workflow name, TTL and Workflow Id fields

 

Then open Configuration, then Webhooks. Add https://YOUR_DOMAIN/api/webhooks/filestack with the type Workflow, and select Create in its Secret column. That secret signs every delivery to this webhook.

The Developer Portal webhooks page with a Workflow webhook row whose empty secret has a Create button
The Developer Portal webhooks page with https://YOUR_DOMAIN/api/webhooks/filestack entered and the type set to Workflow, and a Workflow webhook row whose empty secret has a Create button

 

Now the project. This uses Next.js 16.3.8 with the App Router and TypeScript, and filestack-js 4.3.9:

npx create-next-app@latest scan-demo   # accept the defaults: TypeScript, App Router
cd scan-demo
npm install filestack-js@^4.3.9

.env.local:

NEXT_PUBLIC_FILESTACK_API_KEY=YOUR_API_KEY
NEXT_PUBLIC_FILESTACK_WORKFLOW_ID=YOUR_WORKFLOW_ID
FILESTACK_APP_SECRET=YOUR_APP_SECRET          # Security section of the application
FILESTACK_WEBHOOK_SECRET=YOUR_WEBHOOK_SECRET  # the one you just created next to the webhook

Only the two NEXT_PUBLIC_ values reach the browser. The key and the workflow ID name the application. The two secrets stay on the server, and they are different secrets.

Sign the upload policy in a Route Handler

With application security on, every upload carries a signed policy. lib/filestack.ts signs policies and deletes files:

// lib/filestack.ts
import { createHmac } from 'node:crypto';

type Policy = { call: string[]; expiry: number; handle?: string };

export function sign(policy: Policy) {
  const encoded = Buffer.from(JSON.stringify(policy))
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_'); // keep the = padding
  const signature = createHmac('sha256', process.env.FILESTACK_APP_SECRET!)
    .update(encoded)
    .digest('hex');
  return { policy: encoded, signature };
}

export async function deleteFile(handle: string) {
  const expiry = Math.floor(Date.now() / 1000) + 60;
  const { policy, signature } = sign({ call: ['remove'], handle, expiry });
  const params = new URLSearchParams({
    key: process.env.NEXT_PUBLIC_FILESTACK_API_KEY!,
    policy,
    signature,
  });
  const res = await fetch(`https://www.filestackapi.com/api/file/${handle}?${params}`, {
    method: 'DELETE',
  });
  if (!res.ok) throw new Error(`delete ${handle} failed: ${res.status} ${await res.text()}`);
}

Node’s toString('base64url') drops the = padding, and the workflow then refuses the policy with the policy was not properly URL-safe base64 encoded. Encoding with base64 and swapping the two characters keeps it.

The policy route signs for five minutes:

// app/api/filestack/policy/route.ts
import { sign } from '@/lib/filestack';

export async function GET() {
  // Check your own session here, so the route never signs for an anonymous caller.
  const expiry = Math.floor(Date.now() / 1000) + 300;
  return Response.json(
    sign({ call: ['pick', 'store', 'read', 'convert', 'runWorkflow'], expiry }),
  );
}

Each call has a job. pick and store cover the upload, read and convert let the workflow start on the stored file, and runWorkflow runs it.

Upload from a client component with the workflow attached

The component fetches a policy, uploads with the workflow ID as the third argument to client.upload, and saves the handle with its job ID. The file shows as Scanning, with no link, until the status route says otherwise:

// components/ScanUpload.tsx
'use client';

import { useState } from 'react';
import * as filestack from 'filestack-js';

const API_KEY = process.env.NEXT_PUBLIC_FILESTACK_API_KEY!;
const WORKFLOW_ID = process.env.NEXT_PUBLIC_FILESTACK_WORKFLOW_ID!;

type Row = { handle: string; name: string; status: string };

export default function ScanUpload() {
  const [rows, setRows] = useState<Row[]>([]);

  const update = (handle: string, status: string) =>
    setRows((all) => all.map((r) => (r.handle === handle ? { ...r, status } : r)));

  async function poll(handle: string) {
    for (let i = 0; i < 30; i++) {
      await new Promise((resolve) => setTimeout(resolve, 2000));
      const { status } = await fetch(`/api/uploads/${handle}`).then((r) => r.json());
      if (status !== 'pending') return update(handle, status);
    }
    update(handle, 'still scanning');
  }

  async function onChange(event: React.ChangeEvent<HTMLInputElement>) {
    const input = event.target.files?.[0];
    if (!input) return;

    const security = await fetch('/api/filestack/policy').then((r) => r.json());
    const client = filestack.init(API_KEY, { security });
    const file = await client.upload(input, {}, { workflows: [WORKFLOW_ID] });
    const { jobid, error } = file.workflows[WORKFLOW_ID];
    if (!jobid) throw new Error(`workflow did not start: ${error}`);

    await fetch('/api/uploads', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ handle: file.handle, jobid }),
    });
    setRows((all) => [...all, { handle: file.handle, name: input.name, status: 'pending' }]);
    poll(file.handle);
  }

  return (
    <div>
      <input type="file" onChange={onChange} />
      <ul>
        {rows.map((r) => (
          <li key={r.handle}>
            {r.status === 'clean' ? (
              <a href={`/api/files/${r.handle}`}>{r.name}</a>
            ) : (
              <span>
                {r.name}, {r.status === 'pending' ? 'Scanning' : r.status}
              </span>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

Render <ScanUpload /> from app/page.tsx. Polling stops after a minute and shows “still scanning”. If you use the picker instead, pass the same ID as storeTo: { workflows: [WORKFLOW_ID] }. Where the client boundary goes is covered in the picker in the Next.js App Router.

The two upload routes save the pending record and report its status:

// app/api/uploads/route.ts
import { savePending } from '@/lib/uploads';

export async function POST(request: Request) {
  const { handle, jobid } = await request.json();
  if (typeof handle !== 'string' || typeof jobid !== 'string' || !jobid) {
    return Response.json({ error: 'handle and jobid are required' }, { status: 400 });
  }
  savePending(handle, jobid);
  return Response.json({ handle, status: 'pending' }, { status: 201 });
}
// app/api/uploads/[handle]/route.ts
import { getUpload } from '@/lib/uploads';

export async function GET(
  _request: Request,
  { params }: { params: Promise<{ handle: string }> },
) {
  const { handle } = await params;
  const upload = getUpload(handle);
  if (!upload) return Response.json({ status: 'unknown' }, { status: 404 });
  return Response.json({ status: upload.status, infections: upload.infections });
}

Receive the verdict on a webhook

The webhook route does the work. It reads the body as text before anything else, because the signature covers the exact bytes of the delivery. Parse the JSON first and serialize it again, and the hash no longer matches.

// app/api/webhooks/filestack/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
import { after } from 'next/server';
import { deleteFile } from '@/lib/filestack';
import { getUpload, markClean, markFailed, markRemoved } from '@/lib/uploads';

const MAX_AGE_SECONDS = 300;

function verify(raw: string, timestamp: string | null, signature: string | null) {
  if (!timestamp || !signature) return false;
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(age <= MAX_AGE_SECONDS)) return false; // every delivery attempt carries a fresh timestamp
  const expected = createHmac('sha256', process.env.FILESTACK_WEBHOOK_SECRET!)
    .update(`${timestamp}.${raw}`)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}

export async function POST(request: Request) {
  const raw = await request.text();
  const ok = verify(
    raw,
    request.headers.get('fs-timestamp'),
    request.headers.get('fs-signature'),
  );
  if (!ok) return new Response('invalid signature', { status: 401 });

  const payload = JSON.parse(raw);
  const job = payload.text;
  if (
    payload.action !== 'fs.workflow' ||
    job?.workflow !== process.env.NEXT_PUBLIC_FILESTACK_WORKFLOW_ID
  ) {
    return new Response(null, { status: 204 });
  }

  const handle: string = job.sources[0];
  const existing = getUpload(handle);
  if (existing && existing.jobid === job.jobid && existing.status !== 'pending') {
    return new Response(null, { status: 204 }); // already handled this job
  }

  if (job.status === 'Failed') {
    console.error(`scan failed for ${handle}: ${job.error}`);
    markFailed(handle, job.jobid);
    return new Response(null, { status: 204 });
  }

  const verdict = job.results.virus_scan.data;
  if (verdict.infected) {
    markRemoved(handle, job.jobid, verdict.infections_list);
    after(() => deleteFile(handle));
  } else {
    markClean(handle, job.jobid);
  }
  return new Response(null, { status: 204 });
}

In order, the handler:

  • Builds the string FS-Timestamp, a full stop, then the raw body, and signs it with HMAC-SHA256 using the webhook secret. It compares the result with FS-Signature in constant time and answers 401 on a mismatch. The app secret signs policies, not webhooks, so using it here fails every delivery.
  • Rejects a timestamp more than five minutes old. A retried delivery carries a new FS-Timestamp, so the window refuses replayed requests and lets retries through.
  • Answers 204 to anything that isn’t an fs.workflow event for this workflow, so it isn’t retried.
  • Finds the job under text, the handle at text.sources[0] and the verdict at text.results.virus_scan.data.
  • Skips a job it has already recorded. A delivery can arrive more than once, and the webhook idempotency and retries guide covers the patterns for a real database.
  • Marks the file clean, or marks it removed and deletes it inside after(), which runs once the response has gone back.
  • Marks a failed job scan_failed and keeps the file blocked. A failed job has no results, and text.error says why.

Any response other than 200, 201 or 204 is retried after 5 minutes, 30 minutes and 12 hours, then marked as not delivered. The webhook signature verification reference lists that schedule. That’s why every path that isn’t a signature failure answers 204, and answers fast.

This is the clean delivery for a PDF, trimmed to the fields the handler reads:

{
  "id": "ffbccc95-42cb-4d22-aa96-5fd1601f1562",
  "action": "fs.workflow",
  "timestamp": 1791176768,
  "text": {
    "workflow": "28072b5a-f52a-4959-a3c8-69d79238f2ab",
    "jobid": "becf1eea-c1d1-4737-91e3-29b7e00632f2",
    "sources": ["YaPxwlD8TCCq35Z0QhUA"],
    "results": {
      "virus_scan": { "data": { "infected": false, "infections_list": [] } }
    },
    "status": "Finished"
  }
}

And the EICAR test file:

{
  "id": "9450cc72-6279-44a7-bbe9-3ebae6ea388d",
  "action": "fs.workflow",
  "timestamp": 1791176771,
  "text": {
    "workflow": "28072b5a-f52a-4959-a3c8-69d79238f2ab",
    "jobid": "1713f45f-4288-4f21-ba9d-0b686a65c7bd",
    "sources": ["Ccy74VGkRdWEO6vbCX9Z"],
    "results": {
      "virus_scan": {
        "data": {
          "infected": true,
          "infections_list": ["content.malicious.eicar-test-signature"]
        }
      }
    },
    "status": "Finished"
  }
}

For these two files the webhook arrived 10 seconds after the job’s createdAt.

Serve a file only after it passes

Your app decides who sees a handle. The person who uploaded the file already has its URL. Everyone else gets it from this route, so this route is the gate:

// app/api/files/[handle]/route.ts
import { getUpload } from '@/lib/uploads';

export async function GET(
  _request: Request,
  { params }: { params: Promise<{ handle: string }> },
) {
  const { handle } = await params;
  const upload = getUpload(handle);
  if (upload?.status !== 'clean') {
    return Response.json({ status: upload?.status ?? 'unknown' }, { status: 409 });
  }
  return Response.redirect(`https://cdn.filestackcontent.com/${handle}`, 302);
}

In Next.js 16, params is a Promise, so it’s awaited. Anything that isn’t clean gets a 409 with its status, and a clean file gets a 302 to its CDN URL. If your application requires signed delivery, redirect to a URL signed with a read policy for that one handle and a short expiry.

The records live in a stand-in store keyed by handle:

// lib/uploads.ts
// Stand-in store. Replace these functions with your database.
export type Status = 'pending' | 'clean' | 'removed' | 'scan_failed';

export type Upload = {
  handle: string;
  jobid?: string;
  status: Status;
  infections?: string[];
};

const g = globalThis as unknown as { uploads?: Map<string, Upload> };
const uploads = (g.uploads ??= new Map<string, Upload>());

export function savePending(handle: string, jobid: string) {
  if (!uploads.has(handle)) uploads.set(handle, { handle, jobid, status: 'pending' });
}

export function markClean(handle: string, jobid: string) {
  uploads.set(handle, { handle, jobid, status: 'clean' });
}

export function markRemoved(handle: string, jobid: string, infections: string[]) {
  uploads.set(handle, { handle, jobid, status: 'removed', infections });
}

export function markFailed(handle: string, jobid: string) {
  uploads.set(handle, { handle, jobid, status: 'scan_failed' });
}

export function getUpload(handle: string) {
  return uploads.get(handle);
}

A Map lives in one server process and is gone on restart, so swap these functions for your database before deploying. Because savePending never overwrites a record and the webhook always writes, it doesn’t matter whether the upload route or the webhook gets there first.

Remove infected files

deleteFile in lib/filestack.ts signs a policy with one call, remove, for one handle, valid for 60 seconds, and sends it to DELETE https://www.filestackapi.com/api/file/HANDLE. A leaked URL from that call reaches nothing else. Once the delete runs, the file is gone from storage and from the CDN:

curl -I https://cdn.filestackcontent.com/Ccy74VGkRdWEO6vbCX9Z
HTTP/2 404

Test it with the EICAR file

Build and start the app, then open a tunnel so the webhook can reach your machine:

npm run build && npm start
ngrok http 3000

Cloudflare’s cloudflared tunnel --url http://localhost:3000 does the same job with no account.

Put the tunnel’s URL, followed by /api/webhooks/filestack, on the webhook in the portal.

The EICAR test file comes from the European Institute for Computer Antivirus Research. Antivirus engines agree to detect it, so it tests a scanner without real malware. Create it with one line:

printf '%s' 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > eicar.com

An antivirus on your own machine may quarantine the file as soon as it’s written.

Upload a PDF and eicar.com. The PDF goes from Scanning to a link. eicar.com goes from Scanning to removed, and its CDN URL returns 404.

The demo upload list in two states. While the scan runs, invoice.pdf and eicar.com both read Scanning. After the webhook, invoice.pdf is a link and eicar.com reads removed
The demo upload list in two states. While the scan runs, invoice.pdf and eicar.com both read Scanning. After the webhook, invoice.pdf is a link and eicar.com reads removed

 

From a terminal, the app and the CDN agree:

Terminal output: the infected EICAR file is removed (409, CDN 404) while the clean PDF redirects to its CDN URL
Terminal output. The status route reports eicar.com as removed with content.malicious.eicar-test-signature, its download route returns 409, its CDN URL returns HTTP/2 404, and the clean PDF’s download route returns a 302 to its CDN URL

 

When the scan or the webhook fails

Symptom Cause Fix
jobid is "" and error reads call is not allowed: (call: read) The upload policy has no read Add read
the policy was not properly URL-safe base64 encoded The policy was encoded with base64url, which drops the padding Encode with base64 and swap the two characters
Job status is Failed with call is not allowed: (call: convert) The policy has no convert Add convert
Every delivery gets 401 The body was parsed before hashing, or the app secret was used instead of the webhook secret Hash request.text() with the webhook secret
No webhook arrives The webhook type isn’t Workflow, the tunnel is down, or a preview deployment sits behind login protection Check the type, the tunnel and the deployment’s protection setting

The virus detection announcement linked below has the full failure table, from the workflow status endpoint to unknown job IDs.

Files already in storage were uploaded before this workflow existed, so run the scan over them once. The script for scanning files already in storage runs the workflow on a stored handle and deletes what comes back infected.

Frequently asked questions

How do I scan uploaded files for viruses in Next.js?

Attach a workflow with a virus_detection task to the upload, pass its workflow ID as the third argument to client.upload, and record the file as pending with the job ID that comes back. The verdict arrives on your webhook, and a download route serves the file only once it is marked clean.

Why does the upload finish before the scan result arrives?

The scan reads the file where it is stored, so it only starts once storage is done. The upload response tells you a scan is running and nothing more. For the PDF and EICAR test files used here, the webhook arrived about ten seconds after the job started.

How do I verify the Filestack webhook signature in a Route Handler?

Read the body with request.text() before anything else, build the string FS-Timestamp, a full stop, then that raw body, and hash it with HMAC-SHA256 using the webhook secret. Compare the result with FS-Signature in constant time. Parsing the JSON first, or using the app secret instead of the webhook secret, makes every delivery fail with 401.

What happens to a file that comes back infected?

The handler marks it removed with its infections_list and deletes it with a signed remove call for that one handle. Its CDN URL then returns 404, and the download route answers 409 instead of redirecting.

Read More →