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.

New to pompelmi? Read Getting started with antivirus scanning in Node.js first, then come back for the R2-specific setup.

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.

Never commit credentials to source control. Use environment variables or a secrets manager. For production deployments on Cloudflare Workers or a VPS, use the platform's secret injection mechanism.

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'));
R2 does not charge for egress to the internet, which makes it cost-effective for serving user-uploaded files publicly. Combine this with Cloudflare's CDN to serve clean uploads from the edge.

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.

R2 does not currently support S3-style bucket policies based on object tags. Use Cloudflare Workers with a fetch interceptor to enforce scan-status checks at the access layer if you need policy-based enforcement.

Next steps