Skip to content

CWE-377: Insecure Temporary File - JavaScript/Node.js

Overview

Insecure temporary file creation in Node.js occurs when applications create files with predictable names, insecure permissions, or without proper cleanup. Node's own fs.mkdtemp(), and the tmp and temp packages, handle those three correctly and should be used instead of building a path in a shared directory by hand.

Primary Defence: Use tmp or temp npm packages, or fs.mkdtemp() for secure temporary directory creation with unpredictable names and automatic cleanup.

Common Vulnerable Patterns

Predictable filename in /tmp

const fs = require('fs');
const path = require('path');

// VULNERABLE - Predictable filename using PID
function saveUserData(userData) {
  const tempFile = `/tmp/userdata_${process.pid}.txt`;

  // Attackers can predict the PID
  fs.writeFileSync(tempFile, userData);

  processFile(tempFile);
  // File not deleted - persists in /tmp
}

Why this is vulnerable: Predictability matters here because of what it lets an attacker do before the application runs, not what it lets them read afterwards. A local account that can work out the path creates a symbolic link at it first; the application's own open() then follows the link and writes the data wherever the attacker pointed it, with the application's privileges. Disclosure is the mild outcome - the same primitive appends to a file the attacker cannot write to directly.

process.pid is readable by any local user from ps, so this is not a guess. Node's fs.writeFile() follows symlinks like every other write, and it offers 'wx' as an open flag - O_CREAT | O_EXCL - which is the call that would fail instead of following.

Fixed filename in shared directory

const fs = require('fs');

// VULNERABLE - Fixed filename, race condition
function exportCredentials(apiKey, secret) {
  const tempFile = '/tmp/credentials.txt';

  // Multiple processes might use same filename
  // Default permissions may be insecure (0666 - umask)
  const data = `API_KEY=${apiKey}\nSECRET=${secret}\n`;
  fs.writeFileSync(tempFile, data);

  // File not cleaned up
}

Why this is vulnerable: No prediction is needed: the attacker creates the path first and the application writes through it. The sticky bit on /tmp prevents deleting or renaming another user's file, not claiming an unused name, so it does not cover this.

The same fixed name also makes two instances of the service collide with each other, which is how this usually gets noticed - as flaky behaviour under load rather than as a security finding.

Using timestamp for filename

// VULNERABLE - Timestamp-based filename is predictable
function createTempLog() {
  const timestamp = Date.now();
  const tempFile = `/tmp/log_${timestamp}.txt`;

  // Attacker can predict the timestamp
  fs.writeFileSync(tempFile, 'Sensitive log data');

  return tempFile;
}

Why this is vulnerable: Date.now() is millisecond-resolution and roughly known to anyone who can trigger the operation, so the attacker pre-creates links across a window rather than guessing a value.

Appending Math.random() is the reflex repair and does not fix it either: Math.random() is not a CSPRNG, V8's implementation is xorshift128+, and its state is recoverable from a handful of outputs. Use crypto.randomBytes() if a name must be built by hand - but the exclusive-create flag is what makes it safe, not the entropy.

Insecure permissions

const fs = require('fs');

// VULNERABLE - World-readable permissions
function saveSensitiveData(data) {
  const tempFile = '/tmp/sensitive.txt';

  // writeFileSync uses default mode 0666 (modified by umask)
  // Often results in 0644 (world-readable)
  fs.writeFileSync(tempFile, data);

  // Any user can read this file
}

Why this is vulnerable: The mode argument to fs.writeFile() and fs.open() is masked by the process umask, so it sets a ceiling rather than a permission - and it applies only when the call actually creates the file. Without the 'wx' flag, writing to a path an attacker pre-created opens their file and keeps their mode.

Modes are also ignored on Windows beyond the read-only bit, so a permission argument that reads as the fix on Linux is doing nothing at all on another platform in the same deployment.

Not cleaning up temporary files

const fs = require('fs');

// VULNERABLE - Temp files accumulate
function processSensitiveData(data) {
  const random = Math.floor(Math.random() * 10000);
  const tempFile = `/tmp/data_${random}.tmp`;

  fs.writeFileSync(tempFile, data);

  const result = analyze(tempFile);
  // File never deleted - sensitive data persists
  return result;
}

Why this is vulnerable: Files left in os.tmpdir() persist until the host clears it - at boot, or after ten days under systemd-tmpfiles, or never on Windows without Disk Cleanup - so the data outlives the request by an interval nobody chose.

Cleanup registered on process.on('exit') is the common attempt, and it covers less than it appears to for two different reasons. It cannot run at all for SIGKILL or a container stop that escalates past its grace period, because the process is not asked to stop - it is stopped. And even when it does run, Node requires the listener to be synchronous: the event loop is already finished, so fs.unlink() with a callback never gets one, and only fs.unlinkSync() completes.

It is worth being accurate about the case it does cover, because that is where this advice is often overstated: an uncaught exception does fire the handler, verified on Node 24. A try/finally around the work is still the better shape, because it removes the file at the point of failure rather than at process teardown, and it runs whether or not anything else about the shutdown path is correct.

Using os.tmpdir() without secure filename

const os = require('os');
const fs = require('fs');
const path = require('path');

// VULNERABLE - Predictable filename even with tmpdir()
function createInsecureTemp(data) {
  const tmpDir = os.tmpdir();
  const tempFile = path.join(tmpDir, `data_${Date.now()}.txt`);

  // Predictable filename
  fs.writeFileSync(tempFile, data);

  return tempFile;
}

Why this is vulnerable: os.tmpdir() returns the shared temp root, which on Unix is world-writable - it locates the file correctly and confers nothing else. Every weakness above still applies to a path built inside it.

The distinction worth keeping is between the directory and the creation. Node has no mkstemp equivalent in its standard library, so either open with 'wx' at an unpredictable name and retry on EEXIST, or create a private directory with fs.mkdtemp() - which returns a 0700 path no other account can enter, making a predictable filename inside it harmless.

Secure Patterns

Using fs.mkdtemp for secure temporary directory

const fs = require('fs').promises;
const path = require('path');
const os = require('os');

async function processWithSecureTemp(data) {
  // Create secure temporary directory with unpredictable name
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'secure-'));

  try {
    const tempFile = path.join(tmpDir, 'data.txt');

    // Write data to file in secure directory
    await fs.writeFile(tempFile, data, { mode: 0o600 }); // Owner read/write only

    // Process the file
    const result = await processFile(tempFile);

    return result;

  } finally {
    // Clean up: delete file and directory
    try {
      await fs.rm(tmpDir, { recursive: true, force: true });
    } catch (err) {
      console.error('Failed to clean up temp directory:', err);
    }
  }
}

Why this works: fs.mkdtemp() appends six random characters to the prefix and creates the directory itself, at mode 0700 on Unix - owner-only, so other users cannot list or enter it. The documented guarantee is the creation, not the entropy: Node specifies six random characters without promising a CSPRNG behind them, and the underlying mkdtemp(3) varies by platform. That is enough here because the call fails rather than reusing an existing path, so an attacker cannot pre-create the directory or point a symlink at it whether or not they can guess the name. Files created inside it stay out of reach of other users even at default permissions, because reaching them means traversing the directory first. The try/finally runs fs.rm() with recursive: true on the error path as well as the success path, removing the directory and everything in it; the 0o600 mode on the file itself is defense in depth.

Using tmp package with auto-cleanup

const tmp = require('tmp');
const fs = require('fs').promises;

// Configure tmp to auto-cleanup on process exit
tmp.setGracefulCleanup();

async function processSecureData(data) {
  // Create temp file with secure options
  const tmpFile = tmp.fileSync({
    mode: 0o600,        // Owner read/write only
    prefix: 'secure-',
    postfix: '.txt',
    discardDescriptor: true
  });

  try {
    // Write data to secure temp file
    await fs.writeFile(tmpFile.name, data);

    // Process the file
    const result = await processFile(tmpFile.name);

    return result;

  } finally {
    // Explicit cleanup (tmp also auto-cleans on exit)
    tmpFile.removeCallback();
  }
}

Why this works: The tmp package generates cryptographically random filenames and creates files with restrictive permissions (mode: 0o600 = owner read/write only). The discardDescriptor: true option closes the file descriptor after creation, preventing file descriptor leaks while still allowing you to access the file by path. The removeCallback() in the finally block is what actually deletes the file, on both the success and error paths.

setGracefulCleanup() is a backstop for the narrower case where the process ends before that finally runs, and it is worth being precise about how narrow. It works through a process.on('exit') handler, and Node emits 'exit' only when process.exit() is called or the event loop runs out of work - which includes an uncaught exception, but not a process ended by a signal it has no listener for. SIGKILL and SIGSTOP cannot be listened for at all, and a container stopped with SIGTERM exits without the handler unless the application installs one. Only synchronous work runs inside an 'exit' handler, so tmp deletes with unlinkSync; anything queued as a promise there is discarded. Treat it as covering graceful exits and handled signals, and do not let it stand in for the explicit cleanup.

Using tmp package with async/await

const tmp = require('tmp-promise');
const fs = require('fs').promises;

async function processWithTmpPromise(data) {
  // Create temp file that auto-cleans up
  const { path: tempPath, cleanup } = await tmp.file({
    mode: 0o600,
    prefix: 'secure-',
    postfix: '.txt'
  });

  try {
    // Write sensitive data
    await fs.writeFile(tempPath, data);

    // Process the file
    const result = await processFile(tempPath);

    return result;

  } finally {
    // Clean up temp file
    await cleanup();
  }
}

Why this works: tmp-promise is a promise-based wrapper around the tmp package, with the same properties - cryptographically random names, 0600 permissions - behind async/await. tmp.file() returns a cleanup() function that deletes the file; awaiting it in a finally block deletes on the error path as well as the success path. The file is created with those permissions before any data is written, so there is no window between creation and permission setting in which another user can open it.

Using temp package

const temp = require('temp');
const fs = require('fs').promises;

// Track files for automatic cleanup
temp.track();

async function processWithTemp(data) {
  // temp.openSync creates the file with O_CREAT | O_EXCL | O_TRUNC | O_RDWR
  // at mode 0600 - no chmod afterwards is needed
  const info = temp.openSync({
    prefix: 'secure-',
    suffix: '.txt'
  });

  try {
    // Write data
    await fs.writeFile(info.path, data);

    // Process the file
    const result = await processFile(info.path);

    return result;

  } finally {
    // Clean up (or rely on temp.track() for automatic cleanup)
    temp.cleanupSync();
  }
}

Why this works: The temp package wraps Node's file operations with tracking and cleanup. temp.track() registers exit handlers that delete every tracked file when the process exits, even if explicit cleanup is forgotten. temp.openSync() opens with O_CREAT | O_EXCL | O_TRUNC | O_RDWR at mode 0600, so the file is created exclusively and owner-only in one step. The cleanupSync() in the finally block is what deletes the file in normal operation, with track() as the backup for a process that exits without reaching it; calling it synchronously there is safe because the async processing has already finished.

One caveat specific to this package: temp builds its filenames from the date, the process ID and Math.random(), none of which is unpredictable to a local attacker. Exclusive creation is what protects the file, so the name must not be treated as a secret - do not, for example, use the generated path as a capability handed to another process. Where the name itself matters, tmp (above) draws its characters from crypto.randomBytes().

Custom secure temp file creation

const fs = require('fs').promises;
const crypto = require('crypto');
const path = require('path');
const os = require('os');

async function createSecureTempFile(prefix = 'secure-', suffix = '.tmp') {
  const tmpDir = os.tmpdir();

  // Generate cryptographically random filename
  const randomName = crypto.randomBytes(16).toString('hex');
  const tempPath = path.join(tmpDir, `${prefix}${randomName}${suffix}`);

  // Create file with secure permissions (owner only)
  await fs.writeFile(tempPath, '', { 
    mode: 0o600,
    flag: 'wx'  // Fail if file exists (prevents race conditions)
  });

  return tempPath;
}

// Usage
async function processData(data) {
  const tempPath = await createSecureTempFile();

  try {
    // Write sensitive data
    await fs.writeFile(tempPath, data);

    // Process the file
    const result = await processFile(tempPath);

    return result;

  } finally {
    // Always clean up
    try {
      await fs.unlink(tempPath);
    } catch (err) {
      if (err.code !== 'ENOENT') {
        console.error('Failed to delete temp file:', err);
      }
    }
  }
}

Why this works: Using crypto.randomBytes(16) generates 16 bytes (128 bits) of cryptographically strong random data from the OS's secure random number generator (/dev/urandom on Unix, CryptGenRandom on Windows), providing 2^128 possible filenames. The hexadecimal encoding produces a 32-character string that's both unpredictable and filesystem-safe. The flag: 'wx' option in fs.writeFile() combines write and exclusive flags, making file creation atomic - it fails if the file exists, preventing race conditions. Setting mode: 0o600 ensures owner-only permissions from the moment of creation. Build the path by hand like this when you need a filename pattern the packages above cannot produce; the security properties are the same ones they give you.

Temporary file manager class

const fs = require('fs').promises;
const crypto = require('crypto');
const path = require('path');
const os = require('os');

class TempFileManager {
  constructor() {
    this.files = new Set();
    this.cleanupRegistered = false;

    // Register cleanup on process exit. Only synchronous work runs inside an
    // 'exit' handler, so this calls cleanupSync() rather than cleanup().
    // SIGTERM needs its own listener - without one Node terminates on the
    // signal and never emits 'exit'. SIGKILL cannot be listened for.
    if (!this.cleanupRegistered) {
      process.on('exit', () => this.cleanupSync());
      for (const [signal, code] of [['SIGINT', 130], ['SIGTERM', 143]]) {
        process.on(signal, () => {
          this.cleanupSync();
          process.exit(code);
        });
      }
      this.cleanupRegistered = true;
    }
  }

  async createTempFile(options = {}) {
    const {
      prefix = 'temp-',
      suffix = '.tmp',
      mode = 0o600
    } = options;

    const tmpDir = os.tmpdir();
    const randomName = crypto.randomBytes(16).toString('hex');
    const filePath = path.join(tmpDir, `${prefix}${randomName}${suffix}`);

    // Create with secure permissions
    await fs.writeFile(filePath, '', { mode, flag: 'wx' });

    // Track for cleanup
    this.files.add(filePath);

    return filePath;
  }

  async createTempDir(options = {}) {
    const { prefix = 'tempdir-' } = options;
    const tmpDir = os.tmpdir();

    // Use mkdtemp for secure directory creation
    const dirPath = await fs.mkdtemp(path.join(tmpDir, prefix));

    // Ensure secure permissions
    await fs.chmod(dirPath, 0o700);

    this.files.add(dirPath);

    return dirPath;
  }

  async cleanup() {
    for (const file of this.files) {
      try {
        const stat = await fs.stat(file);
        if (stat.isDirectory()) {
          await fs.rm(file, { recursive: true, force: true });
        } else {
          await fs.unlink(file);
        }
      } catch (err) {
        if (err.code !== 'ENOENT') {
          console.error(`Failed to clean up ${file}:`, err);
        }
      }
    }
    this.files.clear();
  }

  cleanupSync() {
    const fsSync = require('fs');
    for (const file of this.files) {
      try {
        const stat = fsSync.statSync(file);
        if (stat.isDirectory()) {
          fsSync.rmSync(file, { recursive: true, force: true });
        } else {
          fsSync.unlinkSync(file);
        }
      } catch (err) {
        if (err.code !== 'ENOENT') {
          console.error(`Failed to clean up ${file}:`, err);
        }
      }
    }
    this.files.clear();
  }
}

// Usage
async function processMultipleFiles(dataArray) {
  const manager = new TempFileManager();

  try {
    const files = await Promise.all(
      dataArray.map(async (data) => {
        const file = await manager.createTempFile({ prefix: 'data-' });
        await fs.writeFile(file, data);
        return file;
      })
    );

    return await batchProcess(files);

  } finally {
    await manager.cleanup();
  }
}

Why this works: The TempFileManager class centralizes temp file lifecycle management, ensuring consistent security practices. Registering handlers for exit, SIGINT and SIGTERM covers graceful shutdown, Ctrl+C, an uncaught exception, and the signal a container runtime sends first. It does not cover every ending, and the gaps are worth knowing rather than assuming away: Node emits 'exit' only when process.exit() is called or the event loop empties, so a signal with no listener terminates the process without it, and SIGKILL and SIGSTOP cannot be listened for at all. The handlers call cleanupSync() rather than cleanup() because only synchronous work completes inside an 'exit' handler - a promise queued there is discarded. Explicit cleanup in a finally is still what deletes the files in normal operation. Using crypto.randomBytes(16) for filenames provides cryptographic randomness (2^128 possibilities). The flag: 'wx' in fs.writeFile() creates files exclusively (fails if exists), preventing race conditions. Tracking paths in a Set lets one call remove everything the manager created, and ignoring ENOENT keeps that call quiet about files something else has already deleted. The shape suits an application that creates temp files across several code paths and wants one place that knows about all of them.

Express file upload with secure temp storage

const express = require('express');
const multer = require('multer');
const tmp = require('tmp-promise');
const fs = require('fs').promises;

const app = express();

// Configure multer to use secure temp storage
const storage = multer.diskStorage({
  destination: async (req, file, cb) => {
    try {
      // Create secure temp directory for each upload
      const { path: tmpDir } = await tmp.dir({
        mode: 0o700,
        prefix: 'upload-',
        unsafeCleanup: false
      });
      cb(null, tmpDir);
    } catch (err) {
      cb(err);
    }
  },
  filename: (req, file, cb) => {
    // Generate secure random filename
    const crypto = require('crypto');
    const randomName = crypto.randomBytes(16).toString('hex');
    const ext = require('path').extname(file.originalname);
    cb(null, `${randomName}${ext}`);
  }
});

const upload = multer({ 
  storage,
  limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
});

app.post('/upload', upload.single('file'), async (req, res) => {
  const uploadedFile = req.file;

  try {
    // Set secure permissions
    await fs.chmod(uploadedFile.path, 0o600);

    // Validate and process the file
    if (!await isValidFile(uploadedFile.path)) {
      return res.status(400).send('Invalid file');
    }

    const result = await processUploadedFile(uploadedFile.path);
    res.json({ success: true, result });

  } finally {
    // Clean up temp file and directory
    try {
      await fs.unlink(uploadedFile.path);
      await fs.rmdir(require('path').dirname(uploadedFile.path));
    } catch (err) {
      console.error('Cleanup failed:', err);
    }
  }
});

Why this works: Multer's diskStorage decides where each upload lands and under what name. tmp.dir() gives every upload its own directory at mode: 0o700, so one user's upload stays unreachable from another's even if the filename is guessed. crypto.randomBytes(16) makes that filename unpredictable while the original extension is preserved for type validation, and fs.chmod() to 0o600 after Multer has written the file leaves it owner-only. isValidFile() runs before any processing, and the finally block deletes both the file and its dedicated directory whichever way the handler ends.

Next.js API route with secure temp files

import tmp from 'tmp-promise';
import fs from 'fs/promises';
import formidable from 'formidable';

export const config = {
  api: {
    bodyParser: false,
  },
};

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  // Create secure temp directory
  const { path: tmpDir, cleanup } = await tmp.dir({
    mode: 0o700,
    prefix: 'nextjs-upload-'
  });

  try {
    // Parse form with secure temp storage
    const form = formidable({
      uploadDir: tmpDir,
      keepExtensions: true,
      maxFileSize: 10 * 1024 * 1024, // 10MB
    });

    const [fields, files] = await new Promise((resolve, reject) => {
      form.parse(req, (err, fields, files) => {
        if (err) reject(err);
        else resolve([fields, files]);
      });
    });

    const uploadedFile = files.file[0];

    // Set secure permissions
    await fs.chmod(uploadedFile.filepath, 0o600);

    // Process the file
    const result = await processFile(uploadedFile.filepath);

    res.status(200).json({ success: true, result });

  } catch (error) {
    console.error('Upload error:', error);
    res.status(500).json({ error: 'Upload failed' });

  } finally {
    // Clean up temp directory and all files
    await cleanup();
  }
}

Why this works: tmp.dir() gives each request its own directory at mode: 0o700, and pointing formidable at it through uploadDir means the parser writes there rather than into the shared temp root. maxFileSize caps what a single upload can consume, and keepExtensions: true keeps the file type available for validation. Setting mode: 0o600 via fs.chmod() after parsing leaves the file owner-only. The cleanup() from tmp-promise in the finally block removes the directory and everything in it on the error path as well as the success path, so no temp files persist between requests.

Common Pitfalls

  • Random filename generated but written with plain fs.writeFile(): The default flag is 'w', which creates-or-truncates. If two requests happen to generate a colliding name, or a path is somehow pre-created, the write silently succeeds against the existing file instead of failing. Only flag: 'wx' (as shown in the custom temp file example above) makes creation atomic and exclusive.
  • Passing mode to fs.writeFile()/fs.open() against a path that already exists: The mode option only applies when the file is created. Calling fs.writeFile(path, data, { mode: 0o600 }) against a path created earlier by a previous call does not retroactively tighten its permissions - an existing permissive file silently keeps its original mode.
  • A tmp.fileSync() call in a request handler with no setGracefulCleanup() and a swallowed error: If the handler throws before reaching finally/removeCallback(), the file leaks for the life of the process. tmp.setGracefulCleanup() protects against process crashes, not against an application error that's caught and logged without ever calling cleanup.

Additional Resources