Skip to content

CWE-377: Insecure Temporary File - Python

Overview

Insecure temporary file creation in Python occurs when an application picks a predictable name, leaves permissions open to other local accounts, or writes into a shared directory another user can get to first. Python's tempfile module generates the name and creates the file in a single step, which is the part that makes the difference.

Primary Defence: Use tempfile.NamedTemporaryFile() or tempfile.mkstemp() with secure defaults for unpredictable names, restricted permissions, and automatic cleanup.

Common Vulnerable Patterns

Predictable filename in /tmp

import os

# VULNERABLE - Predictable filename using PID
def save_user_data(user_data):
    temp_file = f"/tmp/userdata_{os.getpid()}.txt"

    # Attackers can predict the PID and pre-create this file
    with open(temp_file, 'w') as f:
        f.write(user_data)  # Sensitive data exposed

    process_file(temp_file)
    # 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.

A process id is not even a guess. Any local user reads it from ps or /proc, and on Linux it is bounded by /proc/sys/kernel/pid_max, which defaults to 32768 - a space small enough to pre-create every candidate.

Fixed filename in shared directory

# VULNERABLE - Fixed filename that multiple processes might use
def export_credentials(api_key, secret):
    temp_file = "/tmp/credentials.txt"

    # Race condition: Another user could pre-create this file
    # World-readable by default (umask dependent)
    with open(temp_file, 'w') as f:
        f.write(f"API_KEY={api_key}\n")
        f.write(f"SECRET={secret}\n")

    return temp_file

Why this is vulnerable: No prediction is required at all: the attacker creates /tmp/credentials.txt first and waits, and open(..., 'w') writes through whatever is there.

The sticky bit on /tmp is what gets cited as protection and does not cover this. It prevents unprivileged users deleting or renaming files they do not own; it says nothing about creating a name nobody has claimed. A service that writes this path on every run loses the race whenever it restarts, so the attacker only needs to win once and can retry indefinitely.

Insecure file permissions

import os

# VULNERABLE - World-readable permissions (0644)
def save_session_data(session_token):
    temp_file = "/tmp/session_data.txt"

    # Create file with insecure permissions
    fd = os.open(temp_file, os.O_CREAT | os.O_WRONLY, 0o644)
    os.write(fd, session_token.encode())
    os.close(fd)

    # Any user on the system can read this file
    # ls -la shows: -rw-r--r-- (world-readable)

Why this is vulnerable: The 0o644 is the obvious half, and it is worth knowing that the argument is a ceiling rather than a setting - the kernel applies mode & ~umask, so a process with a stricter umask gets less than it asked for and one with umask 000 gets exactly 0644, world-readable.

The subtler half is that this mode may never be applied. open(2) uses the mode argument only when O_CREAT causes the file to be created; there is no O_EXCL here, so if an attacker created /tmp/session_data.txt first, os.open() opens their file and leaves their permissions in place. Requesting 0o600 on this line would not have fixed it.

Using timestamp for filename

import time

# VULNERABLE - Timestamp-based filename is predictable
def create_temp_log():
    timestamp = int(time.time())
    temp_file = f"/tmp/log_{timestamp}.txt"

    # Attacker can predict the timestamp
    with open(temp_file, 'w') as f:
        f.write("Sensitive log data")

    return temp_file

Why this is vulnerable: One-second resolution leaves nothing to guess. An attacker who knows roughly when the job runs pre-creates a symlink for every second in the window - a few thousand ln -s calls - and waits for one to be used.

The general form is worth carrying past this example: any name derived from observable state has this property, so replacing the timestamp with a counter, a hash of the timestamp, or the two combined changes the arithmetic and not the outcome. Entropy only helps when it comes from somewhere the attacker cannot see, and even then it is the atomic exclusive creation that does the work.

Not cleaning up temporary files

# VULNERABLE - Temp files accumulate in /tmp
def process_sensitive_data(data):
    import random
    temp_file = f"/tmp/data_{random.randint(1000, 9999)}.tmp"

    with open(temp_file, 'w') as f:
        f.write(data)

    result = analyze(temp_file)
    # File never deleted - sensitive data persists
    return result

Why this is vulnerable: random.randint(1000, 9999) offers nine thousand names from random.Random, a Mersenne Twister rather than a CSPRNG - so the space is small enough to enumerate outright and the sequence is reconstructible from a few observed outputs besides.

The missing cleanup is the separate problem, and "temporary" describes the intent rather than the lifetime. /tmp is commonly cleared only at boot, or after ten days by systemd-tmpfiles, so the data outlives the process by however long the host runs. It is also captured by anything that snapshots the filesystem - container image commits, volume backups, forensic images - none of which knows the file was meant to be transient.

Reusing a fixed directory in the shared temp root

# VULNERABLE - a predictable directory in a world-writable root
import os
import tempfile

def app_temp_dir(app_name):
    """Create, or reuse, an application temp directory"""
    path = os.path.join(tempfile.gettempdir(), f"{app_name}-{os.getuid()}")

    # DANGEROUS: succeeds whatever is already at that path, and applies
    # mode 0o700 only to directories it actually creates
    os.makedirs(path, mode=0o700, exist_ok=True)

    # DANGEROUS: chmod does not change who owns the directory, and it
    # follows symlinks to wherever they point
    os.chmod(path, 0o700)

    return path

# ATTACK:
# 1. Attacker creates /tmp/myapp-1000 first, as a directory they own or as
#    a symlink to one
# 2. makedirs(exist_ok=True) returns quietly and applies no mode; chmod
#    re-applies 0700 to a directory the attacker still owns
# 3. Every temp file the application writes inside it is readable by them

Why this is vulnerable: A uid is public and tempfile.gettempdir() is world-writable on Unix, so the path is both predictable and reachable by any local account. os.makedirs(..., exist_ok=True) is the specific mistake: exist_ok suppresses the error that would have told you somebody else got there first, and the mode argument applies only to directories the call creates. The os.chmod() afterwards looks like a repair and is not one - permissions are not ownership, so the attacker keeps their access, and because chmod follows symlinks it can end up loosening something else entirely. Replacing makedirs with os.mkdir in a try is the start of a fix, but only with the checks that go with it.

Secure Patterns

Using tempfile.NamedTemporaryFile with auto-deletion

import tempfile

def process_sensitive_data(data):
    """Secure temp file with automatic cleanup"""
    # Creates file with:
    # - Unpredictable name, and the file claimed in the same syscall
    # - Restrictive permissions (0600 - owner only)
    # - Auto-deletion when closed
    with tempfile.NamedTemporaryFile(mode='w', delete=True, suffix='.txt') as temp_file:
        temp_file.write(data)
        temp_file.flush()  # Ensure data is written

        # Process the file while it's open
        result = process_file(temp_file.name)

    # File automatically deleted when context exits
    return result

Why this works:

  • Unpredictable name, claimed atomically: the name is eight characters drawn from random.Random, not a CSPRNG, so treat it as hard to guess rather than secret. What makes it safe is the open: O_CREAT | O_EXCL | O_NOFOLLOW at mode 0600, which fails rather than reusing a path an attacker pre-created, and refuses to follow a symlink planted there. tempfile then retries with a new name
  • Automatic secure permissions: Sets 0600 (owner read/write only) on Unix, preventing other users from accessing the file
  • Automatic deletion: delete=True (the default) removes the file as soon as it is closed
  • Guaranteed cleanup: the with statement closes the file on every path out of the block, including an exception, so the data does not outlive the function
  • Data integrity: flush() pushes buffered data to disk before process_file() opens the path, so it does not read a half-written file

When to use: the default choice in Python, and enough for most temp file work without any cleanup code of your own.

Using tempfile with explicit deletion

import tempfile
import os

def export_user_report(user_data):
    """Secure temp file with manual cleanup"""
    temp_file = None
    try:
        # Create with delete=False to manage lifecycle manually
        # Still gets secure name and permissions
        temp_file = tempfile.NamedTemporaryFile(
            mode='w',
            delete=False,
            prefix='user_report_',
            suffix='.csv'
        )

        temp_file.write(user_data)
        temp_file.close()

        # Process the closed file
        send_email_attachment(temp_file.name)

    finally:
        # Always clean up, even on error
        if temp_file and os.path.exists(temp_file.name):
            os.unlink(temp_file.name)

Why this works: Using delete=False with tempfile.NamedTemporaryFile() allows manual lifecycle management while still getting the unpredictable name, exclusive creation and 0600 permissions. This is necessary when the file must be closed before processing (some tools can't read from open files) or when the file needs to outlive the function scope. The try-finally block ensures cleanup happens even if exceptions occur during processing. Closing the file before processing releases file locks and flushes all data to disk. The os.path.exists() check prevents errors if the file was already deleted.

Using tempfile.mkstemp for file descriptor

import tempfile
import os

def save_credentials_securely(api_key, secret):
    """Secure temp file using mkstemp"""
    # mkstemp returns (file_descriptor, path)
    # Creates file with mode 0600 (owner read/write only)
    fd, temp_path = tempfile.mkstemp(suffix='.txt', prefix='creds_')

    try:
        # Write using file descriptor (more secure)
        os.write(fd, f"API_KEY={api_key}\n".encode())
        os.write(fd, f"SECRET={secret}\n".encode())
        os.close(fd)

        # Process the file
        load_credentials(temp_path)

    finally:
        # Always delete the temp file
        if os.path.exists(temp_path):
            os.unlink(temp_path)

Why this works: tempfile.mkstemp() returns a low-level file descriptor and path, creating the file atomically with mode 0600 (owner read/write only) to prevent race conditions where an attacker might create a file with the same name between name generation and file creation. Writing through that descriptor with os.write(), rather than reopening the path with open(), means the data lands in the exact file mkstemp() created and not in a symlink or file an attacker substituted in the meantime. Closing the descriptor (os.close(fd)) before processing prevents file lock issues. The try-finally ensures cleanup even on errors, and os.path.exists() check prevents errors if the file was already deleted.

The exact open is O_CREAT | O_EXCL | O_NOFOLLOW at mode 0600, retrying with a new name on collision - which is why the equivalent written by hand, tempfile.mktemp() followed by os.open(), is not worth having. It reimplements mkstemp() and stops being safe the moment someone simplifies the flags; Python's documentation deprecates mktemp() for that reason.

Using temporary directory with secure permissions

import tempfile
import os
import shutil

def process_multiple_files(files_data):
    """Create secure temporary directory for multiple files"""
    # Create temp directory with mode 0700 (owner only)
    with tempfile.TemporaryDirectory(prefix='secure_work_') as temp_dir:
        # temp_dir has permissions 0700 - only owner can access

        for filename, data in files_data.items():
            file_path = os.path.join(temp_dir, filename)

            # Files inherit directory's secure context
            with open(file_path, 'w') as f:
                f.write(data)

        # Process all files in secure directory
        result = batch_process(temp_dir)

    # Directory and all contents auto-deleted
    return result

Why this works: tempfile.TemporaryDirectory() creates a directory with mode 0700 (owner-only execute/read/write), ensuring that only the owning user can access, list, or create files within it. This provides isolation from other users on shared systems. The directory name is unpredictable, and mkdtemp creates it rather than reusing an existing path, so another local user cannot have got there first. Files created within this directory inherit the secure context, and even if they had world-readable permissions, other users still couldn't access them without directory permissions. The context manager automatically deletes the directory and all its contents recursively when exiting, even on exceptions, preventing sensitive data from persisting. This suits processing several related files at once: they are all protected by the same directory and all removed with it.

Opening with flags mkstemp does not set

If you need O_SYNC, O_APPEND or O_TMPFILE, open the file yourself inside a directory only you can enter, so the filename does not have to carry the security:

import os
import tempfile

def create_synced_temp_file(sensitive_data):
    """Open with custom flags, inside a private directory"""
    with tempfile.TemporaryDirectory() as work_dir:
        # work_dir is mode 0700, so a chosen filename inside it is fine
        path = os.path.join(work_dir, 'payload.dat')

        fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_SYNC, 0o600)
        try:
            os.write(fd, sensitive_data.encode())
        finally:
            os.close(fd)

        return process_file(path)
    # Directory and contents removed on exit

Why this works: tempfile.TemporaryDirectory() creates a 0700 directory that no other account can enter, so the predictable name payload.dat inside it is not a weakness - nobody else can create, replace or read that path. This is the general answer when the temp API does not expose an option you need: move the security to the directory rather than reinventing the file creation. O_EXCL is still there as a second line of defence, and the context manager removes the whole directory even if process_file() raises.

User-specific temporary directory

import tempfile
import os
import atexit
import shutil

class SecureTempManager:
    """Manage user-specific temporary directory"""

    def __init__(self, app_name):
        self.app_name = app_name
        self.temp_dir = self._create_private_temp_dir()
        # Register cleanup on exit
        atexit.register(self.cleanup)

    def _create_private_temp_dir(self):
        """Create a private temp directory for this process"""
        # mkdtemp creates the directory at mode 0700 and fails rather than
        # adopting an existing path, so there is nothing for another local
        # account to have prepared in advance.
        return tempfile.mkdtemp(prefix=f"{self.app_name}-")

    def create_temp_file(self, data, suffix='.tmp'):
        """Create temp file in user-specific directory"""
        fd, path = tempfile.mkstemp(
            suffix=suffix,
            dir=self.temp_dir
        )

        try:
            os.write(fd, data.encode())
        finally:
            os.close(fd)

        return path

    def cleanup(self):
        """Clean up all temporary files"""
        if os.path.exists(self.temp_dir):
            shutil.rmtree(self.temp_dir)

# Usage
temp_manager = SecureTempManager('myapp')
temp_file = temp_manager.create_temp_file("sensitive data")
# Cleanup happens automatically on exit

Why this works: One private directory per process gives every temp file the application creates the same protection at once: mkdtemp() makes it at mode 0700, so no other local account can enter it, list it or replace anything inside. tempfile.mkstemp() within that directory adds 0600 on the files themselves. atexit.register() removes the tree on a normal exit even if the developer forgets to call cleanup(), and shutil.rmtree() takes the files with it.

The distinction that matters is between creating the directory and merely ensuring it exists, which is what os.makedirs(..., exist_ok=True) on a fixed path loses (see Reusing a fixed directory in the shared temp root above). mkdtemp() cannot be used that way: it always creates, so there is never an existing directory for it to adopt.

A directory that has to survive between runs

import os
import stat
import tempfile

def open_app_dir(base, name):
    """Return a directory under `base` that only this user can use"""
    path = os.path.join(base, name)

    try:
        # os.mkdir raises rather than adopting an existing path
        os.mkdir(path, 0o700)
        return path
    except FileExistsError:
        pass

    # Something is already there. It is usable only if it is a real
    # directory, owned by this user, closed to everyone else. lstat, so a
    # symlink is reported as a symlink instead of being followed.
    info = os.lstat(path)
    mode = stat.S_IMODE(info.st_mode)
    if not stat.S_ISDIR(info.st_mode):
        raise RuntimeError(f"{path} exists and is not a directory")
    if info.st_uid != os.getuid():
        raise RuntimeError(f"{path} is owned by uid {info.st_uid}")
    if mode & 0o077:
        raise RuntimeError(f"{path} is open to other users (mode {mode:04o})")

    return path

def process_with_cache_dir(data):
    """Use a directory that outlives the process"""
    # A base only this user can write to, not the shared temp root
    base = os.environ.get('XDG_RUNTIME_DIR') or os.path.expanduser('~/.cache')
    app_dir = open_app_dir(base, 'myapp')

    with tempfile.NamedTemporaryFile(
        mode='w',
        delete=True,
        dir=app_dir,
        prefix='work_',
        suffix='.dat'
    ) as temp_file:
        temp_file.write(data)
        temp_file.flush()

        return process_file(temp_file.name)

Why this works: Some applications genuinely need a path that persists across runs - a cache, a spool directory, a socket location. tempfile.mkdtemp() cannot provide that, because a new random name each time is the whole point of it, so the directory has to be created by name and the checks have to be written out. os.mkdir() supplies the atomic half: it either creates the directory at 0700 or raises FileExistsError, and there is no window in which it exists more permissively. The lstat() checks cover what it cannot distinguish - a directory left by yesterday's run looks exactly like one an attacker planted this morning - so type, ownership and mode are all confirmed before anything is written, and raising is the correct outcome when they disagree. Choosing $XDG_RUNTIME_DIR or the user's home directory over tempfile.gettempdir() matters just as much: those bases are not world-writable, so the race the checks defend against mostly cannot start. The mode test rejects any group or other bits rather than requiring exactly 0700, because os.mkdir() applies the process umask and a stricter umask can legitimately produce a narrower mode. The ownership check is Unix-only - os.getuid() does not exist on Windows, where the per-user temp directory and its inherited ACL already provide the isolation this code is reconstructing.

Secure temp file for Django file upload

from django.core.files.uploadedfile import UploadedFile
import tempfile
import os

def handle_file_upload(uploaded_file: UploadedFile):
    """Securely handle file upload with temp storage"""
    # Use NamedTemporaryFile for uploaded content
    with tempfile.NamedTemporaryFile(
        delete=False,
        suffix=os.path.splitext(uploaded_file.name)[1]
    ) as temp_file:
        # Write uploaded chunks securely
        for chunk in uploaded_file.chunks():
            temp_file.write(chunk)

        temp_path = temp_file.name

    try:
        # Virus scan or validate the file
        if not is_safe_file(temp_path):
            raise ValueError("File failed security check")

        # Process the validated file
        result = process_uploaded_file(temp_path)

    finally:
        # Always clean up
        if os.path.exists(temp_path):
            os.unlink(temp_path)

    return result

Why this works: Django's UploadedFile.chunks() method streams file data in manageable chunks (typically 2.5MB), preventing memory exhaustion from large uploads. Using tempfile.NamedTemporaryFile(delete=False) creates a file with 0600 permissions and unpredictable name while allowing you to close the file before processing (required by some validation tools). Extracting the file extension from the original filename via os.path.splitext() preserves file type information needed for validation. The pattern validates the file (virus scan, magic number check, size limits) before processing, ensuring only safe files are used. The try-finally ensures cleanup even if validation fails or processing throws exceptions.

Framework-Specific Guidance

Flask file upload with secure temp storage

from flask import Flask, request
import tempfile
import os

app = Flask(__name__)

@app.route('/upload', methods=['POST'])
def upload_file():
    """Securely handle file upload"""
    if 'file' not in request.files:
        return 'No file uploaded', 400

    uploaded_file = request.files['file']

    # mkstemp() creates the file 0600 in one step and hands back the descriptor
    fd, temp_path = tempfile.mkstemp(suffix='.upload')

    try:
        # Write through the descriptor mkstemp already opened. Passing
        # temp_path to something that reopens it works too, but only because
        # mkstemp created the file first - a name on its own is not a file
        with os.fdopen(fd, 'wb') as temp_file:
            uploaded_file.save(temp_file)

        result = process_upload(temp_path)
        return result, 200

    finally:
        os.unlink(temp_path)

Why this works: mkstemp() creates the file exclusively at mode 0600 and returns the descriptor for it, so there is no window in which the upload is on disk under a mode anyone else can read. os.fdopen() takes ownership of that descriptor, which means the with closes it on every path including an exception - and Werkzeug's FileStorage.save() accepts an open binary stream, so nothing has to reopen the file by name. The try starts immediately after the file exists, so temp_path is always bound by the time finally runs; putting the creation inside the try block's preamble is what leaks files when the write raises.

FastAPI with secure temporary files

from fastapi import FastAPI, UploadFile
import tempfile
import os
import aiofiles

app = FastAPI()

@app.post("/upload")
async def upload_file(file: UploadFile):
    """Async secure file upload handling"""
    # Create the temp file 0600 and keep its descriptor
    fd, temp_path = tempfile.mkstemp(suffix='.upload')

    try:
        # aiofiles.open() accepts the descriptor and closes it on exit, so it
        # cannot be leaked if the read or the write raises
        async with aiofiles.open(fd, 'wb') as f:
            while chunk := await file.read(64 * 1024):
                await f.write(chunk)

        result = await process_file_async(temp_path)
        return {"status": "success", "result": result}

    finally:
        os.unlink(temp_path)

Why this works: the descriptor from mkstemp() is handed straight to aiofiles.open(), so the async with closes it however the block exits. Writing to temp_path instead and calling os.close(fd) after the write looks equivalent and is not: any exception before the os.close() line leaks the descriptor for the life of the process. On Windows that also breaks the cleanup path - os.unlink() on a file with an open handle raises PermissionError: [WinError 32], so the finally fails too and the upload stays on disk. Reading the upload in chunks rather than with a bare await file.read() keeps a large upload from being held in memory in full, which is most of the reason to be using a temp file at all.

Common Pitfalls

  • Any use of tempfile.mktemp(): Python's documentation deprecates it, and the reason is structural - it returns a name without creating the file, leaving a window in which an attacker can create a file or symlink at that path before the application opens it. mkstemp() and NamedTemporaryFile() close the gap by generating the name and creating the file in one operation. Following mktemp() with os.open(..., os.O_CREAT | os.O_EXCL, 0o600) does close the race, but it is mkstemp() written out by hand, and it survives review only until someone simplifies the flags.
  • Building a path from tempfile.gettempdir() with string formatting: os.path.join(tempfile.gettempdir(), f"myfile_{name}.tmp") gets the right base directory but reverts to a predictable, developer-chosen filename - gettempdir() only answers "where"; the randomness and atomic creation still have to come from the tempfile functions that generate the name.
  • Setting delete=False and forgetting cleanup is now manual: The automatic-cleanup guarantee that makes NamedTemporaryFile() secure by default only applies when delete=True (the default). Once delete=False is set - often for cross-platform reasons - cleanup becomes exactly as easy to skip as the vulnerable patterns shown earlier on this page.

Additional Resources