Secure File Downloads in Node.js with Permission Checks
Serving sensitive files securely is a critical requirement for modern web applications. This article provides a straightforward guide on how to implement secure file downloads in Node.js by validating user access permissions on the fly before delivering any data. You will learn how to set up an Express route, authenticate requests, verify permissions against a database, and safely stream files to authorized users without exposing direct storage paths.
Keep Files Out of Public Directories
To secure your files, never store them in public static directories
(like /public or /dist). If a file is in a
public directory, anyone with the URL can bypass your application logic
entirely. Instead, store sensitive files in a restricted directory
outside the web root, or in a private cloud storage bucket (like AWS
S3). Access to these files must be mediated strictly through a
specialized Node.js endpoint.
Implementing the Download Route
The standard pattern for secure downloads involves a single API route that accepts a file identifier, authenticates the requesting user, checks their permissions, and streams the file payload.
Here is a complete, production-ready implementation using Node.js and Express:
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
// Mock database and authentication middleware
const { authenticateUser, hasAccessToFile } = require('./authService');
// Restricted storage directory
const STORAGE_DIR = path.join(__dirname, 'secure_storage');
app.get('/download/:fileId', authenticateUser, async (req, res) => {
const { fileId } = req.params;
const userId = req.user.id;
try {
// 1. Validate user permissions on the fly
const userHasAccess = await hasAccessToFile(userId, fileId);
if (!userHasAccess) {
return res.status(403).json({ error: 'Access denied' });
}
// 2. Retrieve file metadata (e.g., actual filename, path) from database
const fileMetadata = await getFileMetadata(fileId);
if (!fileMetadata) {
return res.status(404).json({ error: 'File not found' });
}
// Prevent Directory Traversal Attacks by sanitizing the path
const safeFilename = path.basename(fileMetadata.physicalName);
const filePath = path.join(STORAGE_DIR, safeFilename);
// 3. Verify file exists on disk
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'File physical storage error' });
}
// 4. Set appropriate headers
res.setHeader('Content-Disposition', `attachment; filename="${fileMetadata.originalName}"`);
res.setHeader('Content-Type', fileMetadata.mimeType || 'application/octet-stream');
// 5. Stream the file to the client
const fileStream = fs.createReadStream(filePath);
fileStream.on('error', (err) => {
console.error('Stream error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Error downloading file' });
}
});
fileStream.pipe(res);
} catch (error) {
console.error('Download handler failed:', error);
res.status(500).json({ error: 'Internal server error' });
}
});Key Architectural Practices
1. Always Stream Files
Never use fs.readFile() to read an entire file into
memory before sending it. For large files, this practice will quickly
exhaust your server’s RAM and cause application crashes. Using
fs.createReadStream() pipes the file data in small chunks
directly to the HTTP response, maintaining a low, stable memory
footprint.
2. Prevent Directory Traversal
Attackers may try to manipulate the fileId or filename
parameter to access sensitive system files (e.g., passing
../../etc/passwd as a file name). Always sanitize file
paths using path.basename() to strip out directory paths,
and map file identifiers to randomly generated UUIDs stored in your
database rather than using user-inputted file names directly on the
filesystem.
3. Set the Correct Headers
Content-Disposition: Setting this header toattachment; filename="filename.ext"forces the browser to download the file rather than trying to render it inline.Content-Type: Explicitly setting the MIME type (e.g.,application/pdf,image/png) ensures the client OS handles the file correctly after downloading.