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
- OWASP: Path Traversal
- A01:2021 Broken Access Control
- A04:2021 Insecure Design
Test your download endpoint with TrueFileSize files: various sizes from 1MB to 100MB.