An SFTP Alternative for Partner Uploads With a Signed Link That Scans for Viruses

Posted on | Last updated on

A vendor’s weekly manifest lands in your shared SFTP account at 2am. A cron job finds it and hands it to the ingest job, which reads whatever it’s given. Four partners log in with the same password, and nobody has rotated it since the account was set up.

This SFTP alternative gives each partner their own upload link instead. A signed security policy makes the link expire and keeps the partner’s files in one folder of your own bucket. Virus Detection scans every file in a workflow, and a webhook starts the ingest only once the scan says the file is clean. We built a small Node service that issues the links and starts the ingest from the webhook. It’s one of the managed file transfer solutions you can put together from Filestack Workflows.

Key takeaways

  • As an SFTP alternative, each partner gets a link carrying a signed policy with an expiry, a storage path and a size limit, and the partner needs no account.
  • The upload page reads the partner’s folder from the policy, so the link is the only thing a partner receives.
  • Virus Detection runs in a workflow attached to the upload, and the verdict reaches your server on a signed fs.workflow webhook.
  • The ingest job starts from the webhook for a clean file, and an infected file is deleted.
  • The largest file a link accepts is set by your plan, from 1 GB on Start to 5 GB on Scale, and by the maxSize you sign.

What an SFTP drop costs to keep running

An SFTP drop is a server you patch, a set of accounts you rotate, and a cron job that trusts every file. When one password is shared, every file looks like it came from the same sender, and any partner can see or overwrite the others’ files.

Before and after. Before, four partners share one password on an SFTP server, a cron job at 2am picks up whatever is there with no scan, and the ingest job reads every file. After, each partner gets a link with a signed policy, a 7-day expiry, the path /partners/acme/ and a 500 MB limit. The upload page stores to your bucket and a workflow runs Virus Detection. Your webhook starts the ingest for a clean file or deletes an infected one
Before and after. Before, four partners share one password on an SFTP server, a cron job at 2am picks up whatever is there with no scan, and the ingest job reads every file. After, each partner gets a link with a signed policy, a 7-day expiry, the path /partners/acme/ and a 500 MB limit. The upload page stores to your bucket and a workflow runs Virus Detection. Your webhook starts the ingest for a clean file or deletes an infected one

A link is the URL of your upload page with a signed policy in it. With application security on, every upload needs a policy, and the policy says what the upload may do and until when. In the new Filestack dashboard, open Configuration, select the Security tab and turn on Use Security under Policy & Signature. The app secret that signs policies is on the same card.

Set up the project with Node 20.6 or later, which reads --env-file, and no packages. .env:

FILESTACK_API_KEY=YOUR_API_KEY
FILESTACK_APP_SECRET=YOUR_APP_SECRET          # Configuration > Security > Policy & Signature
FILESTACK_WEBHOOK_SECRET=YOUR_WEBHOOK_SECRET  # created next to the webhook, below
FILESTACK_WORKFLOW_ID=YOUR_WORKFLOW_ID        # the scanning workflow, below
PUBLIC_URL=https://uploads.example.com        # where partners open the page

The top of server.mjs signs policies and builds a link for one partner:

// Partner upload links: issue a signed link per partner, record each upload,
// and start the ingest job only when the virus scan says the file is clean.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { readFile } from 'node:fs/promises';

const {
  FILESTACK_API_KEY,
  FILESTACK_APP_SECRET,
  FILESTACK_WEBHOOK_SECRET,
  FILESTACK_WORKFLOW_ID,
  PUBLIC_URL = 'http://localhost:3000',
  PORT = '3000',
} = process.env;

const LINK_DAYS = 7;
const MAX_SIZE = 500 * 1024 * 1024; // 500 MB

// Sign a policy with the app secret. Keep the = padding.
function sign(policy) {
  const encoded = Buffer.from(JSON.stringify(policy))
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_');
  const signature = createHmac('sha256', FILESTACK_APP_SECRET).update(encoded).digest('hex');
  return { policy: encoded, signature };
}

function safeEqual(a, b) {
  const x = Buffer.from(a);
  const y = Buffer.from(b);
  return x.length === y.length && timingSafeEqual(x, y);
}

// Read a policy we signed, or return null if the signature does not match
function readPolicy(policy, signature) {
  if (typeof policy !== 'string' || typeof signature !== 'string') return null;
  const expected = createHmac('sha256', FILESTACK_APP_SECRET).update(policy).digest('hex');
  if (!safeEqual(expected, signature)) return null;
  return JSON.parse(Buffer.from(policy, 'base64url').toString());
}

export function issueLink(partner) {
  if (!/^[a-z0-9-]+$/.test(partner)) throw new Error('partner id: lowercase letters, digits and dashes');
  const expiry = Math.floor(Date.now() / 1000) + LINK_DAYS * 24 * 3600;
  const { policy, signature } = sign({
    call: ['pick', 'store', 'read', 'convert', 'runWorkflow'],
    expiry,
    path: `/partners/${partner}/`,
    maxSize: MAX_SIZE,
  });
  const url = new URL('/upload', PUBLIC_URL);
  url.searchParams.set('policy', policy);
  url.searchParams.set('signature', signature);
  return { partner, expires: new Date(expiry * 1000).toISOString(), url: url.toString() };
}

readPolicy is the other half. When the upload page reports a file, the server checks that the policy is one it signed before it trusts the partner name inside it.

Each field in the policy closes one of the gaps in the shared account:

Field Value in the sample What it limits
expiry Now plus 7 days The link stops working after this Unix time
path /partners/acme/ Uploads with this policy store only under this path
maxSize 500 MB The largest file the link accepts
call pick, store, read, convert, runWorkflow What the link may do, which here is upload and start the scan

read and convert are in the list because the workflow starts on the stored file. path is a regular expression, so escape special characters in it. The policy fields are described in our docs on security policies and signatures.

The path applies to uploads that go to your own storage. In the dashboard’s Configuration menu, open Amazon S3, Google Cloud Storage or Azure under Storage Settings and link your container. Every partner folder is then a prefix in that container, and the files stay in your cloud account. It’s the same setting as storing files in your own bucket for any other upload.

A link stays valid until its expiry, so match the expiry to the partner’s schedule. For a weekly manifest, issue a fresh seven-day link each week:

node --env-file=.env server.mjs link acme

It prints:

{
  partner: 'acme',
  expires: '2026-11-09T15:31:57.000Z',
  url: 'https://uploads.example.com/upload?policy=eyJjYWxsIjpbInBpY2siLCJzdG9yZSIsInJlYWQiLCJjb252ZXJ0IiwicnVuV29ya2Zsb3ciXSwiZXhwaXJ5IjoxNzk0MjM4MzE3LCJwYXRoIjoiL3BhcnRuZXJzL2FjbWUvIiwibWF4U2l6ZSI6NTI0Mjg4MDAwfQ%3D%3D&signature=SIGNATURE'
}

The real link ends in a 64-character signature. Send it the way you share the SFTP password today. A partner id with anything other than lowercase letters, digits and dashes throws before a policy is signed.

Build the upload page partners open

Partners open a page built on our file uploader. It reads the policy from its own URL, so it shows the expiry and knows the partner’s folder without a login. upload.html:

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>Send files</title>
  <script src="https://static.filestackapi.com/filestack-js/4.x.x/filestack.min.js"></script>
</head>
<body>
  <h1>Send files</h1>
  <p id="status"></p>
  <div id="picker" style="height: 480px"></div>
  <script>
    const params = new URLSearchParams(location.search);
    const policy = params.get('policy');
    const signature = params.get('signature');
    const status = document.getElementById('status');

    // The policy is base64 JSON, so the page can read the partner folder and the expiry from it
    const signed = JSON.parse(atob(policy.replace(/-/g, '+').replace(/_/g, '/')));
    const expires = new Date(signed.expiry * 1000);

    if (expires < new Date()) {
      status.textContent = 'This link has expired. Ask your contact for a new one.';
    } else {
      status.textContent = `This link works until ${expires.toLocaleString()}.`;
      const client = filestack.init('YOUR_API_KEY', { security: { policy, signature } });

      client.picker({
        displayMode: 'inline',
        container: '#picker',
        fromSources: ['local_file_system'],
        maxFiles: 20,
        maxSize: signed.maxSize,
        storeTo: { location: 's3', path: signed.path, workflows: ['YOUR_WORKFLOW_ID'] },
        uploadConfig: { intelligent: 'fallback' }, // smaller chunks for a part that keeps failing
        onFileUploadFinished: async (file) => {
          await fetch('/uploads', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              policy,
              signature,
              handle: file.handle,
              filename: file.filename,
              jobid: file.workflows?.['YOUR_WORKFLOW_ID']?.jobid,
            }),
          });
        },
      }).open();
    }
  </script>
</body>
</html>

The server fills in YOUR_API_KEY and YOUR_WORKFLOW_ID when it serves the page. Set location to the storage you linked: s3, gcs or azure. With a manifest selected, the partner sees this:

The partner upload page. The heading Send files, the line This link works until 11/9/2026, 11:25:01 PM, and the inline picker listing manifest-2026-11-02.csv, 91B, with Deselect All, Upload More and Upload buttons
The partner upload page. The heading Send files, the line This link works until 11/9/2026, 11:25:01 PM, and the inline picker listing manifest-2026-11-02.csv, 91B, with Deselect All, Upload More and Upload buttons

After Upload, onFileUploadFinished posts the handle and the scan’s job ID to your server. The policy and signature go with them, so your server can check which partner sent the file.

Filestack Discord

Scan every partner file before it is ingested

Virus Detection runs as a task in a workflow, and the workflow ID in storeTo starts it on every file the page uploads. The upload finishes once the file is stored, and the verdict follows on the webhook. In our example, the scan finishes about 10 seconds after the job starts, for a 68-byte test file and a 25 MB file alike.

Create the workflow in the dashboard:

  1. Open Workflows, click Create New and select Build from scratch.
  2. Click Select the first task, open the Intelligence tab and pick virus_detection.
  3. Name the task virus_scan and click Save.
  4. Type a name in Workflow Name, click Save Workflow and copy the Workflow Id into .env.

The task name is the key the verdict arrives under, so the server reads results.virus_scan. We include virus detection for uploads on the Start, Grow and Scale plans, with 1,000 scans a month and then $0.05 a scan, and each scan also counts as one workflow run. Start includes one workflow, Grow three and Scale five. The full task reference and the script for files you already store are in the virus detection setup post.

Then add the webhook:

  1. Open Configuration and select the Authentication tab.
  2. On the Webhooks card, click View webhooks.
  3. Type https://uploads.example.com/webhooks/filestack in Webhook URL, select Workflow in Event type and click Add webhook.
  4. In the new row, click Create under Secret and copy the secret into FILESTACK_WEBHOOK_SECRET.

It’s a different secret from the app secret that signs policies.

Start the ingest job from the webhook

The rest of server.mjs records each upload, receives the verdict, and either starts the ingest or deletes the file. The upload webhooks carry the verdict, signed with your webhook secret:

// Stand-in store keyed by handle. Replace with your database.
const uploads = new Map();

async function startIngest(record) {
  const { policy, signature } = sign({
    call: ['read'],
    handle: record.handle,
    expiry: Math.floor(Date.now() / 1000) + 300,
  });
  const res = await fetch(
    `https://cdn.filestackcontent.com/security=p:${policy},s:${signature}/${record.handle}`,
  );
  if (!res.ok) throw new Error(`download ${record.handle}: ${res.status}`);
  const rows = (await res.text()).trim().split('\n').length - 1;
  console.log(`ingest ${record.partner} ${record.filename}: ${rows} rows`);
}

async function deleteFile(handle) {
  const { policy, signature } = sign({
    call: ['remove'],
    handle,
    expiry: Math.floor(Date.now() / 1000) + 60,
  });
  const params = new URLSearchParams({ key: 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}: ${res.status} ${await res.text()}`);
}

function verifyWebhook(raw, timestamp, signature) {
  if (!timestamp || !signature) return false;
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(age <= 300)) return false;
  const expected = createHmac('sha256', FILESTACK_WEBHOOK_SECRET)
    .update(`${timestamp}.${raw}`)
    .digest('hex');
  return safeEqual(expected, signature);
}

async function onWebhook(raw, headers) {
  if (!verifyWebhook(raw, headers['fs-timestamp'], headers['fs-signature'])) return 401;
  const payload = JSON.parse(raw);
  const job = payload.text;
  if (payload.action !== 'fs.workflow' || job?.workflow !== FILESTACK_WORKFLOW_ID) return 204;

  const record = uploads.get(job.sources[0]);
  if (!record || record.status !== 'pending') return 204; // unknown upload, or already handled

  if (job.status === 'Failed') {
    record.status = 'scan_failed';
    console.error(`scan failed for ${record.handle}: ${job.error}`);
    return 204;
  }

  const verdict = job.results.virus_scan.data;
  if (verdict.infected) {
    record.status = 'removed';
    console.log(`removed ${record.partner} ${record.filename}: ${verdict.infections_list.join(', ')}`);
    deleteFile(record.handle).catch(console.error);
  } else {
    record.status = 'clean';
    startIngest(record).catch(console.error);
  }
  return 204;
}

The end of server.mjs routes the three requests and adds the link command:

async function body(req) {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  return Buffer.concat(chunks).toString();
}

const server = createServer(async (req, res) => {
  const url = new URL(req.url, PUBLIC_URL);

  if (req.method === 'GET' && url.pathname === '/upload') {
    const page = await readFile(new URL('./upload.html', import.meta.url), 'utf8');
    res.writeHead(200, { 'Content-Type': 'text/html' });
    return res.end(
      page
        .replaceAll('YOUR_API_KEY', FILESTACK_API_KEY)
        .replaceAll('YOUR_WORKFLOW_ID', FILESTACK_WORKFLOW_ID),
    );
  }

  if (req.method === 'POST' && url.pathname === '/uploads') {
    const { policy, signature, handle, filename, jobid } = JSON.parse(await body(req));
    const signed = readPolicy(policy, signature);
    if (!signed || signed.expiry < Date.now() / 1000) {
      res.writeHead(403);
      return res.end('link not valid');
    }
    const partner = signed.path.split('/')[2];
    if (!uploads.has(handle)) uploads.set(handle, { handle, partner, filename, jobid, status: 'pending' });
    res.writeHead(201, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ handle, partner, status: 'pending' }));
  }

  if (req.method === 'POST' && url.pathname === '/webhooks/filestack') {
    const status = await onWebhook(await body(req), req.headers);
    res.writeHead(status);
    return res.end();
  }

  res.writeHead(404);
  res.end();
});

if (process.argv[2] === 'link') {
  console.log(issueLink(process.argv[3]));
} else {
  server.listen(Number(PORT), () => console.log(`listening on ${PUBLIC_URL}`));
}

In order, the server:

  1. Records each upload as pending, under the partner named in a policy it signed. A forged or expired policy gets a 403 and no record.
  2. Checks FS-Signature against FS-Timestamp, a full stop and the raw body, signed with the webhook secret, and rejects a timestamp more than five minutes old. A failed check gets a 401.
  3. Ignores events from other workflows and verdicts it has already handled, with a 204, since a delivery can arrive more than once.
  4. Starts the ingest for a clean file. startIngest downloads the file with a read policy for that one handle and counts the manifest’s rows. Replace its body with your ingest job.
  5. Deletes an infected file with a remove policy for that one handle, and logs the detection name. The scan itself leaves the file in storage, so the delete is what removes it.

The verdict is in text.results.virus_scan.data. This is the delivery for the EICAR test file, trimmed to the fields the server reads:

{
  "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"
  }
}

A clean file has "infected": false and an empty infections_list. For a real database, retries and idempotency follow the patterns for upload auth and webhooks at scale.

Run it with a manifest and the EICAR file

Start the server and open a tunnel so the webhook reaches your machine:

node --env-file=.env server.mjs
ngrok http 3000

Use the tunnel’s URL as PUBLIC_URL and in the webhook’s address, then issue a link for acme. The EICAR test file is a string antivirus engines agree to detect, so it tests the scan without real malware. This writes it, decoded from base64:

echo WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo= | base64 -d > eicar.com

Upload manifest-2026-11-02.csv and eicar.com through the link. The server prints:

listening on https://uploads.example.com
removed acme eicar.com: content.malicious.eicar-test-signature
ingest acme manifest-2026-11-02.csv: 2 rows

The manifest’s two rows reach the ingest job, and eicar.com is deleted, so its CDN URL returns 404. The same verdict in a Next.js app is the subject of our post on virus scanning in Next.js.

Large files and interrupted uploads

A partner link accepts files up to your plan’s maximum file size or the maxSize you sign, whichever is smaller:

Plan Maximum file size
Start 1 GB
Grow 2 GB
Scale 5 GB

The picker uploads a large file in 6 MB parts, three at a time, and retries a failed part up to 10 times with a growing delay. With Intelligent Ingestion enabled on your application, the intelligent: 'fallback' setting on the page splits a part that keeps failing into smaller chunks and sends those instead. That fallback is part of how we handle upload reliability on slow and mobile networks. If Intelligent Ingestion isn’t enabled, the page uploads the same way without it. The scan runs on the stored file, so a large file is scanned the same way as a small one.

Symptom Cause Fix
The page says the link has expired The policy’s expiry has passed Issue a new link
/uploads answers 403 link not valid The policy or signature in the link was changed Send the partner the link exactly as issued
The job ID is empty and the workflow entry reads call is not allowed: (call: read) The policy has no read Sign read and convert with runWorkflow
Every webhook delivery gets 401 The app secret was used instead of the webhook secret, or the body was parsed before hashing Hash the raw body with the webhook secret
No webhook arrives The webhook’s event type isn’t Workflow, or the tunnel is down Check the event type and the tunnel

What’s still yours

The partner list, the link schedule and the ingest job stay in your app. Replace the Map with your database, send each partner their link on the schedule their files arrive, and swap the row count in startIngest for the job the cron used to run. Once the last partner has moved to a link, the SFTP server can be switched off. To compare the plans with Virus Detection, see our pricing.

Sign up free for Filestack

Read More →