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.workflowwebhook. - 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
maxSizeyou 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.
Issue a signed upload link per partner as your SFTP alternative
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.
Limit each link to one path and an expiry
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:
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.
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:
- Open Workflows, click Create New and select Build from scratch.
- Click Select the first task, open the Intelligence tab and pick
virus_detection. - Name the task
virus_scanand click Save. - 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:
- Open Configuration and select the Authentication tab.
- On the Webhooks card, click View webhooks.
- Type
https://uploads.example.com/webhooks/filestackin Webhook URL, select Workflow in Event type and click Add webhook. - 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:
- 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.
- Checks
FS-SignatureagainstFS-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. - Ignores events from other workflows and verdicts it has already handled, with a 204, since a delivery can arrive more than once.
- Starts the ingest for a clean file.
startIngestdownloads the file with areadpolicy for that one handle and counts the manifest’s rows. Replace its body with your ingest job. - Deletes an infected file with a
removepolicy 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.
When a link, the scan or the webhook fails
| 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.
Carl is a Product Marketing Manager at Filestack with four years of hands-on experience in React, JavaScript, Django, and Python. He bridges the gap between product and developer, translating how Filestack’s APIs and SDKs actually work into content that’s useful for the engineers building with them. His writing covers file handling workflows, upload integrations, and real-world implementation patterns, written from the perspective of someone who has built with these tools firsthand.
Read More →

