Scanning files before uploading to Cloudflare R2
Cloudflare R2 is an S3-compatible object storage service with no egress fees.
Because R2 exposes the same API as AWS S3, you can use the AWS SDK v3
(@aws-sdk/client-s3) to talk to R2 — you just need to point it
at Cloudflare's custom endpoint.
The scan-then-upload pattern is identical to the S3 guide: scan locally with
pompelmi while the file is still on disk, and only call
PutObjectCommand if the verdict is Verdict.Clean.
Malware never touches R2.
R2 and the S3 API
Cloudflare R2 implements the S3 API with a few differences:
- The endpoint URL is
https://<account-id>.r2.cloudflarestorage.com. - The region must be set to
'auto'. - Credentials are R2 API tokens, not AWS IAM keys.
- S3 object tagging is supported and works identically.
Everything else — PutObjectCommand, streaming, multipart upload —
works without modification.
Install
npm install pompelmi @aws-sdk/client-s3 multer express
Configure the R2 client
The only difference from an S3 client is the endpoint and
region:
const { S3Client } = require('@aws-sdk/client-s3');
const r2 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
},
});
Create an R2 API token in the Cloudflare dashboard under R2 → Manage R2 API tokens. Grant it Object Write permission on your bucket.
Complete Express example
const express = require('express');
const multer = require('multer');
const { scan, Verdict } = require('pompelmi');
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const os = require('os');
const app = express();
const upload = multer({
dest: os.tmpdir(),
limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB
});
const r2 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
},
});
const BUCKET = process.env.R2_BUCKET_NAME;
app.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file provided.' });
}
const tmpPath = req.file.path;
let tmpDeleted = false;
try {
// Step 1 — scan locally before touching R2
const verdict = await scan(tmpPath);
if (verdict === Verdict.Malicious) {
return res.status(400).json({ error: 'Malware detected. Upload rejected.' });
}
if (verdict === Verdict.ScanError) {
return res.status(422).json({ error: 'Scan incomplete. Upload rejected.' });
}
// Step 2 — file is clean, upload to R2
const ext = path.extname(req.file.originalname).toLowerCase();
const r2Key = 'uploads/' + crypto.randomBytes(16).toString('hex') + ext;
await r2.send(new PutObjectCommand({
Bucket: BUCKET,
Key: r2Key,
Body: fs.createReadStream(tmpPath),
ContentType: req.file.mimetype,
// Custom metadata tagging the object as scanned
Metadata: {
'scan-status': 'clean',
'scanned-by': 'pompelmi',
},
}));
// Step 3 — clean up temp file
fs.unlinkSync(tmpPath);
tmpDeleted = true;
return res.json({ status: 'ok', key: r2Key });
} catch (err) {
return res.status(500).json({ error: err.message });
} finally {
if (!tmpDeleted && fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
}
});
app.listen(3000, () => console.log('Listening on :3000'));
Custom metadata for audit trails
R2 supports the same S3-style Metadata (key-value pairs stored
alongside the object) and Tagging (query-string encoded tags).
Adding scan metadata gives you a queryable audit trail:
await r2.send(new PutObjectCommand({
Bucket: BUCKET,
Key: r2Key,
Body: fs.createReadStream(tmpPath),
Metadata: {
'scan-status': 'clean',
'scanned-by': 'pompelmi',
'scan-engine': 'clamav',
'scanned-at': new Date().toISOString(),
},
}));
You can read this metadata back on any GetObject or
HeadObject call and verify that a file was scanned before it was
stored.
fetch interceptor to enforce
scan-status checks at the access layer if you need policy-based enforcement.
Next steps
- Using AWS S3 instead of R2? The pattern is identical — see Scanning files before uploading to AWS S3.
- Running ClamAV in Docker or Kubernetes? See Docker Compose or Kubernetes guides for the TCP-mode setup.
- Want to understand what else you should validate beyond malware? Read the Node.js file upload security checklist.