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