Skip to content
>_ TrueFileSize.com
··8 min read

Secure File Download Implementation — Headers, Signed URLs, and Access Control

Serving files securely is as important as accepting them securely. A misconfigured download endpoint can expose private files, enable path traversal attacks, or be abused for bandwidth theft. Here's how to implement secure file downloads.

Vulnerability: Path Traversal

The most dangerous file download bug — an attacker accesses arbitrary files on your server:

GET /api/download?file=../../../etc/passwd
GET /api/download?file=..%2F..%2F..%2Fetc%2Fpasswd

Vulnerable Code

// DANGEROUS — user controls the file path
app.get('/download', (req, res) => {
  const file = req.query.file;
  res.sendFile(`/var/uploads/${file}`); // Path traversal!
});

Fixed Code

import path from 'path';

const UPLOAD_DIR = path.resolve('/var/uploads');

app.get('/download', authenticate, (req, res) => {
  const requestedFile = req.query.file;

  // 1. Resolve full path
  const fullPath = path.resolve(UPLOAD_DIR, requestedFile);

  // 2. Verify it's within the upload directory
  if (!fullPath.startsWith(UPLOAD_DIR + path.sep)) {
    return res.status(403).json({ error: 'Access denied' });
  }

  // 3. Verify file exists
  if (!fs.existsSync(fullPath)) {
    return res.status(404).json({ error: 'File not found' });
  }

  // 4. Set security headers
  res.setHeader('Content-Disposition', `attachment; filename="${path.basename(fullPath)}"`);
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('Content-Security-Policy', "default-src 'none'");

  res.sendFile(fullPath);
});

Essential Download Headers

function setSecureDownloadHeaders(res, filename, mimeType) {
  // Force download (not inline rendering)
  res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);

  // Set correct MIME type
  res.setHeader('Content-Type', mimeType);

  // Prevent MIME sniffing (stops browser from executing .html as HTML)
  res.setHeader('X-Content-Type-Options', 'nosniff');

  // No script execution from downloads
  res.setHeader('Content-Security-Policy', "default-src 'none'");

  // Prevent caching of sensitive files
  res.setHeader('Cache-Control', 'private, no-cache');
}

Signed URLs (S3 / R2 / GCS)

Don't proxy files through your server — use signed URLs for direct CDN delivery:

// AWS S3 presigned URL
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

async function generateDownloadUrl(key, expiresInSeconds = 300) {
  const command = new GetObjectCommand({
    Bucket: 'private-uploads',
    Key: key,
    ResponseContentDisposition: `attachment; filename="${path.basename(key)}"`,
  });

  return getSignedUrl(s3Client, command, { expiresIn: expiresInSeconds });
}

// Route handler
app.get('/api/download/:fileId', authenticate, async (req, res) => {
  const file = await db.files.findById(req.params.fileId);

  // Access control
  if (file.ownerId !== req.user.id) {
    return res.status(403).json({ error: 'Access denied' });
  }

  // Generate time-limited signed URL
  const url = await generateDownloadUrl(file.s3Key);
  res.redirect(302, url);
});

Cloudflare R2 Signed URL

import { AwsClient } from 'aws4fetch';

const r2 = new AwsClient({
  accessKeyId: process.env.R2_ACCESS_KEY,
  secretAccessKey: process.env.R2_SECRET_KEY,
});

async function signedR2Url(key) {
  const url = new URL(`https://${ACCOUNT_ID}.r2.cloudflarestorage.com/${BUCKET}/${key}`);
  url.searchParams.set('X-Amz-Expires', '300');
  const signed = await r2.sign(url.toString(), { method: 'GET' });
  return signed.url;
}

Rate Limiting Downloads

Prevent bandwidth abuse:

import rateLimit from 'express-rate-limit';

const downloadLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 100,                   // 100 downloads per hour
  message: 'Download limit reached. Try again later.',
  keyGenerator: (req) => req.user?.id || req.ip,
});

app.get('/download/:id', authenticate, downloadLimiter, downloadHandler);

Audit Logging

app.get('/download/:id', authenticate, async (req, res) => {
  const file = await db.files.findById(req.params.id);

  // Log the download
  await db.downloadLogs.create({
    fileId: file.id,
    userId: req.user.id,
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    timestamp: new Date(),
  });

  // Serve file...
});

Security Checklist

| Check | Description | |-------|-------------| | ✅ Path traversal prevention | path.resolve() + startsWith() check | | ✅ Authentication | Only authenticated users can download | | ✅ Authorization | Users can only access their own files | | ✅ Content-Disposition: attachment | Force download, prevent rendering | | ✅ X-Content-Type-Options: nosniff | Prevent MIME sniffing | | ✅ Signed URLs for CDN files | Time-limited, no server proxy needed | | ✅ Rate limiting | Prevent bandwidth abuse | | ✅ Audit logging | Track who downloaded what |

OWASP References

Test your download endpoint with TrueFileSize files: various sizes from 1MB to 100MB.