Skip to content

CWE-377: Insecure Temporary File - C#

Overview

Insecure temporary file creation in C# occurs when applications create files with predictable names, with permissions that let other local users read them, or without cleaning them up afterwards.

Two System.IO.Path methods look interchangeable and are not, and the difference decides which race conditions you have to handle yourself:

Method Returns Creates anything?
Path.GetTempFileName() A full path in the temp directory Yes - a zero-byte file with a .tmp extension
Path.GetRandomFileName() A bare 12-character name, no directory No

Because GetTempFileName() creates the file, the name is already claimed when it returns: nobody can win a race to create that path first. GetRandomFileName() only hands you a string, so the file does not exist yet and the creation is yours to get right - which means FileMode.CreateNew, not Create or OpenOrCreate.

Primary Defence: Create the file atomically with a name you did not have to guess at, and set restrictive permissions as part of the creation rather than after it. Path.GetRandomFileName() with FileMode.CreateNew gives 55 bits of entropy and full control over the creation flags; Path.GetTempFileName() is a reasonable alternative on .NET 8+ but not on .NET Framework or .NET 7 and earlier, for the reasons below.

Common Vulnerable Patterns

Predictable filename in temp directory

using System;
using System.IO;

// VULNERABLE - Predictable filename using PID
public class InsecureTemp
{
    public void SaveUserData(string userData)
    {
        int pid = Environment.ProcessId; // .NET 5+
        string tempFile = $@"C:\Temp\userdata_{pid}.txt";

        // Attackers can predict the PID
        File.WriteAllText(tempFile, userData);

        ProcessFile(tempFile);
        // File not deleted - persists in temp directory
    }
}

Why this is vulnerable: A process ID is not a secret. Any local user can enumerate running processes, so the filename is known to an attacker as soon as the process starts - and File.WriteAllText will happily write into a file that already exists. An attacker who creates C:\Temp\userdata_1234.txt first, or replaces it with a symbolic link, controls where the data lands and can read it afterwards. Hardcoding C:\Temp compounds this: it is a fixed, world-writable location rather than the per-user directory Path.GetTempPath() returns.

Fixed filename in shared directory

using System.IO;

// VULNERABLE - Fixed filename, race condition
public void ExportCredentials(string apiKey, string secret)
{
    string tempFile = @"C:\Temp\credentials.txt";

    // Multiple processes might use same filename
    // Default ACL may allow other users to read
    string data = $"API_KEY={apiKey}\nSECRET={secret}\n";
    File.WriteAllText(tempFile, data);

    // File not cleaned up
}

Why this is vulnerable: The filename is a constant, so there is nothing to predict - an attacker simply pre-creates the path and waits. What makes this the worst example on the page is the payload: an API key and secret written in plaintext to a location every local account can reach, and never deleted. Two concurrent calls also overwrite each other, so this is a correctness bug before it is a security one.

Using timestamp for filename

using System;
using System.IO;

// VULNERABLE - Timestamp-based filename is predictable
public void CreateTempLog()
{
    long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    string tempFile = $@"C:\Temp\log_{timestamp}.txt";

    // Attacker can predict the timestamp
    File.WriteAllText(tempFile, "Sensitive log data");
}

Why this is vulnerable: A Unix timestamp in seconds has roughly one possible value per second, and an attacker who knows roughly when the operation runs can create every candidate in advance - a few thousand files covers an hour. This is the pattern that most often survives review, because the name genuinely does differ on every call and therefore looks random. Varying is not the same as being unguessable.

Not setting restrictive permissions

using System.IO;

// VULNERABLE - Default Windows permissions may be too permissive
public void SaveSensitiveData(string data)
{
    string tempFile = Path.Combine(Path.GetTempPath(), "sensitive.txt");

    // File inherits parent directory permissions
    // May be readable by other users on the system
    File.WriteAllText(tempFile, data);
}

Why this is vulnerable: This one is about permissions rather than the name, and it is why the fix is never "use a random name" alone. File.WriteAllText creates the file with whatever the platform's defaults are: on Unix that is 0666 reduced by the process umask, so the usual result is 0644 - readable by every account on the machine. On Windows the file inherits the temp directory's ACL, which is per-user by default but not if the application has been pointed at a shared directory. An unguessable name does not help once the file exists and is world-readable.

Not cleaning up temporary files

using System;
using System.IO;

// VULNERABLE - Temp files accumulate
public string ProcessSensitiveData(string data)
{
    var random = new Random();
    string tempFile = Path.Combine(Path.GetTempPath(), $"data_{random.Next(10000)}.tmp");

    File.WriteAllText(tempFile, data);

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

Why this is vulnerable: Two defects, and the second is the one to take from this example. new Random() is a non-cryptographic generator, and random.Next(10000) narrows it further to ten thousand names an attacker can create in advance. Separately, there is no try/finally, so any exception from Analyze leaves the file on disk holding sensitive data indefinitely - and the accumulation is itself the problem, because temp directories are rarely cleared and are readable by whoever the directory's permissions allow.

Using Path.GetTempFileName on .NET Framework or .NET 7 and earlier

using System.IO;

// VULNERABLE on .NET Framework and .NET 7 or earlier - on those runtimes
// Windows names are allocated sequentially from tmp0000.tmp to tmpFFFF.tmp
public void ProcessData(string data)
{
    string tempFile = Path.GetTempFileName();

    try
    {
        File.WriteAllText(tempFile, data);
        ProcessFile(tempFile);
    }
    finally
    {
        File.Delete(tempFile);
    }
}

Why this is vulnerable, and on which runtimes: This example is only a finding on the older runtimes, and the version boundary matters more than the API name.

On .NET Framework and .NET 7 and earlier, Path.GetTempFileName() on Windows wraps the Win32 GetTempFileNameW, which draws from 65,536 names of the form tmpXXXX.tmp. Worse than the small range is the allocation order - the names come out sequentially, so observing one tells an attacker what the next several will be. Consecutive calls on .NET 6.0.36 return:

tmpB5D0.tmp, tmpB5D1.tmp, tmpB5D2.tmp, tmpB5D3.tmp, tmpB5D4.tmp, tmpB5D5.tmp

Exhausting the range also makes the method throw, so an unrelated process leaking temp files can break yours.

.NET 8 changed this. The runtime stopped calling GetTempFileNameW and now generates tmp plus six characters from a 32-character alphabet, giving about 2^30 names drawn randomly rather than in sequence - tmp2rgknw.tmp, tmpqygnws.tmp, tmppfupne.tmp. The 65,535-file limit is gone too. On .NET 8 and later this pattern is no longer the finding it used to be, and a scanner rule that flags the call outright will report it on runtimes where it is defensible. Path.GetRandomFileName() is still the stronger choice at 55 bits against 30, but the gap is now a preference rather than a defect.

On Unix the method has always used mkstemps, which creates the file with owner-only permissions - so on Linux and macOS GetTempFileName() produces a better-permissioned file than Path.GetRandomFileName() followed by a plain FileStream, which requests 0666 and lands on 0644 after the usual umask. See Considerations for when that reverses the recommendation.

Secure Patterns

Using Path.GetRandomFileName (cross-platform approach)

using System;
using System.IO;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text;

public class SecureTemp
{
    public void ProcessSecureData(string data)
    {
        // GetRandomFileName returns a name only - it does not create anything,
        // so the creation below is what has to be race-free.
        string tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());

        try
        {
            // SECURE - permissions are applied by the call that creates the file,
            // so there is no moment where it exists with platform defaults.
            using (FileStream stream = CreateOwnerOnly(tempFile))
            {
                byte[] dataBytes = Encoding.UTF8.GetBytes(data);
                stream.Write(dataBytes, 0, dataBytes.Length);
            }

            // Replace ProcessFile() with your actual temp file operations
            // (parsing, validation, transformation, etc.)
            ProcessFile(tempFile);
        }
        finally
        {
            if (File.Exists(tempFile))
            {
                File.Delete(tempFile);
            }
        }
    }

    private static FileStream CreateOwnerOnly(string path)
    {
        if (OperatingSystem.IsWindows())
        {
            // Build the ACL first, then hand it to the create call. Creating the
            // file and then calling SetAccessControl() would leave a window.
            var identity = WindowsIdentity.GetCurrent().User;

            var security = new FileSecurity();
            security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
            security.AddAccessRule(new FileSystemAccessRule(
                identity, FileSystemRights.FullControl, AccessControlType.Allow));

            return new FileInfo(path).Create(
                FileMode.CreateNew,          // Fails if the path already exists
                FileSystemRights.FullControl,
                FileShare.None,
                bufferSize: 4096,
                FileOptions.None,
                security);
        }

        // Unix: 0600 is applied by the underlying open(), not afterwards.
        // FileStreamOptions.UnixCreateMode requires .NET 7 or later.
        return new FileStream(path, new FileStreamOptions
        {
            Mode = FileMode.CreateNew,
            Access = FileAccess.ReadWrite,
            Share = FileShare.None,
            UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite,
        });
    }
}

Why this works:

  • Unguessable filenames: Path.GetRandomFileName() returns 11 random characters from a 32-character alphabet in 8.3 form (wnsqt3cn.xxy), which is 55 bits - enough that an attacker cannot pre-create the path they would need to win a race
  • Superior to predictable approaches: timestamps are guessable within seconds, PIDs can be enumerated by any local user, and new Random() is a non-cryptographic generator
  • The creation is the security boundary, not the name: FileMode.CreateNew throws IOException if the path already exists, so an attacker who does guess the name causes a failure rather than a silent write into their file
  • Permissions applied at creation, on both platforms: the Windows branch passes a FileSecurity into FileInfo.Create() and the Unix branch passes UnixCreateMode into FileStream. Neither creates the file first and tightens it afterwards, which is the ordering mistake in Common Pitfalls below
  • Inheritance removed explicitly: SetAccessRuleProtection(isProtected: true, preserveInheritance: false) drops inherited ACEs, so the file does not pick up whatever the temp directory grants
  • Guaranteed cleanup: try-finally deletes the file even when processing throws

Version note: FileStreamOptions.UnixCreateMode and UnixFileMode are .NET 7+. On .NET 6 there is no way to set the mode as part of the creation - the closest option is to create the file inside a directory that is already owner-only, which is what Directory.CreateTempSubdirectory() does from .NET 7 onward.

When to use: The default choice. Prefer it over hand-rolled RandomNumberGenerator naming, which adds entropy the threat model rarely needs while making it easier to get the creation flags wrong.

Using FileOptions.DeleteOnClose

using System;
using System.IO;

void ProcessWithAutoDelete(string data)
{
    string tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());

    using var stream = new FileStream(
        tempFile,
        FileMode.CreateNew,
        FileAccess.ReadWrite,
        FileShare.None,
        4096,
        FileOptions.DeleteOnClose);

    using (var writer = new StreamWriter(stream, System.Text.Encoding.UTF8, bufferSize: 1024, leaveOpen: true))
    {
        writer.Write(data);
        writer.Flush();
    }

    // do other stuff while stream is still open
    stream.Position = 0;
    using var reader = new StreamReader(stream, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true);
    var roundTrip = reader.ReadToEnd();
}

Why this works:

  • OS-level automatic deletion: FileOptions.DeleteOnClose instructs the OS to delete the file when the last handle closes, so there is no explicit File.Delete() to forget and the file still goes if the application crashes
  • Race condition prevention: FileMode.CreateNew with random filename fails if file exists
  • Exception-safe cleanup: using statement ensures stream disposal (and file deletion) even on exceptions
  • Minimal data exposure: the data is on disk only while the stream is open, which is as short as the workflow allows
  • Default security: Windows typically makes these files user-only accessible (explicit ACL setting still recommended for sensitive data)

When to use this pattern:

  • Processing temp file data entirely through streams (no need to pass file path to external tools)
  • Wanting OS-level guaranteed deletion even if process crashes mid-operation
  • Working with highly sensitive data that should exist for shortest possible time

Avoid this pattern when:

  • Need to pass file path to external processes/tools that open their own handles (file deleted when your stream closes, potentially before external tool finishes)
  • Need to keep file accessible after initial processing (use regular Path.GetRandomFileName() with manual cleanup instead)
using System;
using System.IO;
using System.Security.Cryptography;

public class SecureTempFileManager
{
    public string CreateSecureTempFile(string prefix = "secure_", string suffix = ".tmp")
    {
        string tempDir = Path.GetTempPath();

        // Generate cryptographically random filename (2^128 possibilities - unpredictable)
        // 128 bits, where Path.GetRandomFileName() gives 55
        string randomName = GenerateRandomFileName();
        string tempFile = Path.Combine(tempDir, $"{prefix}{randomName}{suffix}");

        // Create file atomically
        using (var stream = new FileStream(
            tempFile,
            FileMode.CreateNew,  // Fail if exists (prevents race conditions)
            FileAccess.ReadWrite,
            FileShare.None,
            4096,
            FileOptions.None))
        {
            // File created
        }

        // Set restrictive permissions
        SetOwnerOnlyPermissions(tempFile);

        return tempFile;
    }

    private string GenerateRandomFileName()
    {
        byte[] randomBytes = new byte[16];
        using (var rng = RandomNumberGenerator.Create())
        {
            rng.GetBytes(randomBytes);
        }
        return BitConverter.ToString(randomBytes).Replace("-", "").ToLower();
    }

    private void SetOwnerOnlyPermissions(string path)
    {
        if (!OperatingSystem.IsWindows())
        {
            // GetAccessControl()/SetAccessControl() throw PlatformNotSupportedException
            // off Windows. 0600 is the equivalent there, settable directly on .NET 7+.
            File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
            return;
        }

        var fileInfo = new FileInfo(path);
        var fileSecurity = fileInfo.GetAccessControl();

        // Disable inheritance
        fileSecurity.SetAccessRuleProtection(true, false);

        // Remove all existing rules
        foreach (System.Security.AccessControl.FileSystemAccessRule rule in 
                 fileSecurity.GetAccessRules(true, false, typeof(System.Security.Principal.SecurityIdentifier)))
        {
            fileSecurity.RemoveAccessRule(rule);
        }

        // Add owner-only permissions
        var currentUser = System.Security.Principal.WindowsIdentity.GetCurrent();
        var ownerRule = new System.Security.AccessControl.FileSystemAccessRule(
            currentUser.User,
            System.Security.AccessControl.FileSystemRights.FullControl,
            System.Security.AccessControl.AccessControlType.Allow
        );

        fileSecurity.SetAccessRule(ownerRule);
        fileInfo.SetAccessControl(fileSecurity);
    }
}

// Usage
var manager = new SecureTempFileManager();
string tempFile = manager.CreateSecureTempFile();
try
{
    File.WriteAllText(tempFile, sensitiveData);
    ProcessFile(tempFile);
}
finally
{
    File.Delete(tempFile);
}

Why this works:

  • Cryptographic randomness: RandomNumberGenerator.Create() with 16 bytes generates 128-bit random values, providing 2^128 possible filenames
  • More entropy than the built-in helpers: 128 bits against 55 for Path.GetRandomFileName() and 30 for Path.GetTempFileName() on .NET 8+. All three are already far past the point where guessing is the attacker's best move - this matters for the audit trail rather than the threat model
  • Atomic file creation: FileMode.CreateNew fails if file exists, preventing race conditions
  • Explicit permission control: SetOwnerOnlyPermissions() drops the inherited ACEs and grants the current user only, rather than accepting whatever the temp directory's ACL allows
  • Secure before data written: File created empty with minimal permissions before any sensitive data is written
  • Cleanup stays with the caller: CreateSecureTempFile() returns a path and nothing more, so the try-finally in the usage block is what deletes the file

When to use: Environments whose compliance baseline names a key length (HIPAA, PCI DSS). Otherwise the first pattern is the default choice - see Considerations on why entropy is rarely the parameter worth tuning.

Temporary file wrapper class with IDisposable

using System;
using System.IO;

public class TempFile : IDisposable
{
    private readonly string _path;
    private bool _disposed = false;

    public TempFile(string prefix = "temp_", string suffix = ".tmp")
    {
        _path = CreateSecureTempFile(prefix, suffix);
    }

    public string Path => _path;

    private string CreateSecureTempFile(string prefix, string suffix)
    {
        // Use GetRandomFileName for cryptographically random name
        string randomName = System.IO.Path.GetFileNameWithoutExtension(System.IO.Path.GetRandomFileName());
        string tempFile = System.IO.Path.Combine(
            System.IO.Path.GetTempPath(),
            $"{prefix}{randomName}{suffix}"
        );

        // Create file atomically
        using (var stream = File.Open(tempFile, FileMode.CreateNew))
        {
            // File created
        }

        // Set restrictive permissions
        SetOwnerOnlyPermissions(tempFile);

        return tempFile;
    }

    private void SetOwnerOnlyPermissions(string path)
    {
        if (!OperatingSystem.IsWindows())
        {
            // GetAccessControl()/SetAccessControl() throw PlatformNotSupportedException
            // off Windows. 0600 is the equivalent there, settable directly on .NET 7+.
            File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
            return;
        }

        var fileInfo = new FileInfo(path);
        var security = fileInfo.GetAccessControl();
        security.SetAccessRuleProtection(true, false);

        var user = System.Security.Principal.WindowsIdentity.GetCurrent();
        var rule = new System.Security.AccessControl.FileSystemAccessRule(
            user.User,
            System.Security.AccessControl.FileSystemRights.FullControl,
            System.Security.AccessControl.AccessControlType.Allow
        );

        security.SetAccessRule(rule);
        fileInfo.SetAccessControl(security);
    }

    public void Write(string content)
    {
        File.WriteAllText(_path, content);
    }

    public string Read()
    {
        return File.ReadAllText(_path);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                // Dispose managed resources
            }

            // Delete temp file
            try
            {
                if (File.Exists(_path))
                {
                    File.Delete(_path);
                }
            }
            catch (Exception ex)
            {
                // Log error but don't throw in Dispose
                System.Diagnostics.Debug.WriteLine($"Failed to delete temp file: {ex}");
            }

            _disposed = true;
        }
    }

    ~TempFile()
    {
        Dispose(false);
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
}

// Usage
using (var tempFile = new TempFile())
{
    tempFile.Write(sensitiveData);
    ProcessFile(tempFile.Path);
}
// Auto-deleted when disposed

Why this works:

  • Defense in depth cleanup: Both Dispose() (explicit cleanup via using statements) and finalizer (safety net if disposal forgotten)
  • Unguessable names: Path.GetRandomFileName() generates 11 random characters (55 bits), and the wrapper always pairs them with FileMode.CreateNew so the name cannot be claimed first
  • Atomic file creation: FileMode.CreateNew fails if file exists, preventing race conditions
  • Explicit permission control: SetAccessRuleProtection(true, false) removes the inherited permissions, and the FileSystemAccessRule added after it grants access only to the current user
  • Encapsulation: Wrapper class bundles the random name, atomic creation, secure permissions and cleanup in one reusable component, so a caller cannot skip one of them
  • Double-disposal protection: _disposed flag prevents cleanup errors

When to use: C# applications handling sensitive data that need reusable temp file management, with deletion tied to the object's lifetime rather than to a finally block the caller has to remember.

ASP.NET Core file upload with secure temp storage

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.IO;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Threading.Tasks;

[ApiController]
[Route("api/[controller]")]
public class UploadController : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> UploadFile(IFormFile file)
    {
        if (file == null || file.Length == 0)
        {
            return BadRequest("No file uploaded");
        }

        string tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());

        try
        {
            // SECURE - the permissions are part of the create call, so the
            // uploaded bytes are never written to a world-readable file
            using (var stream = CreateOwnerOnly(tempFile))
            {
                await file.CopyToAsync(stream);
            }

            if (!await IsValidFile(tempFile))
            {
                return BadRequest("Invalid file");
            }

            var result = await ProcessUploadedFile(tempFile);

            return Ok(new { success = true, result });
        }
        finally
        {
            if (System.IO.File.Exists(tempFile))
            {
                System.IO.File.Delete(tempFile);
            }
        }
    }

    // Same helper as the pattern above - permissions supplied at creation
    private static FileStream CreateOwnerOnly(string path)
    {
        if (OperatingSystem.IsWindows())
        {
            var security = new FileSecurity();
            security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
            security.AddAccessRule(new FileSystemAccessRule(
                WindowsIdentity.GetCurrent().User,
                FileSystemRights.FullControl,
                AccessControlType.Allow));

            return new FileInfo(path).Create(
                FileMode.CreateNew,
                FileSystemRights.FullControl,
                FileShare.None,
                bufferSize: 4096,
                FileOptions.None,
                security);
        }

        return new FileStream(path, new FileStreamOptions
        {
            Mode = FileMode.CreateNew,
            Access = FileAccess.ReadWrite,
            Share = FileShare.None,
            UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite,
        });
    }

    private Task<bool> IsValidFile(string path)
    {
        // Implement file validation (virus scan, content type check, etc.)
        return Task.FromResult(true);
    }

    private Task<string> ProcessUploadedFile(string path)
    {
        // Process the file
        return Task.FromResult("processed");
    }
}

Why this works:

  • Nothing is written before the permissions are set: the uploaded bytes go into a stream that was already opened owner-only. Creating the file with a plain new FileStream(tempFile, FileMode.CreateNew), copying the upload in, and then tightening the ACL or the mode leaves the content readable for the length of the upload - on Unix that means 0644 for as long as the copy takes, which for a large upload is not a narrow window
  • Efficient streaming: IFormFile.CopyToAsync() streams the upload rather than loading it into memory
  • Atomic creation: FileMode.CreateNew throws if the path exists, so a guessed name causes a failure rather than a write into a file an attacker planted
  • Genuinely cross-platform: the ACL path is guarded by OperatingSystem.IsWindows(). FileSecurity and GetAccessControl()/SetAccessControl() throw PlatformNotSupportedException on Linux and macOS, so an unguarded call is not portable code - it is Windows-only code that compiles everywhere
  • Early validation: the file is validated before processing, so dangerous uploads are rejected first
  • Guaranteed cleanup: try-finally deletes the temp file even when validation fails or processing throws

ASP.NET Core buffers large uploads to a temp file of its own first. Above FormOptions.MemoryBufferThreshold (64 KB by default) the model binder spools the section to disk before the action runs, into the directory named by ASPNETCORE_TEMP if it is set and Path.GetTempPath() otherwise. The action above cannot protect that copy, so on a shared host point the upload buffer at a directory the application owns. Streaming the multipart section yourself with MultipartReader avoids the intermediate file entirely.

Version note: UnixCreateMode is .NET 7+, as in the pattern above. On .NET 6, create the file inside an owner-only directory instead.

When to use: ASP.NET Core applications handling file uploads - especially user-submitted content that may contain sensitive data or malicious payloads. Always validate uploads before processing.

Temporary directory management

using System;
using System.IO;
using System.Security.AccessControl;
using System.Security.Principal;

public sealed class TempDirectoryManager : IDisposable
{
    private readonly DirectoryInfo _dir;
    private bool _disposed;

    public TempDirectoryManager(string prefix = "tempdir_")
    {
        // .NET 7+. On Unix this calls mkdtemp, which creates the directory with
        // 0700 in a single step - there is no moment where it is world-readable.
        _dir = Directory.CreateTempSubdirectory(prefix);

        if (OperatingSystem.IsWindows())
        {
            // Windows has no equivalent one-step call, so replace the inherited
            // ACL before anything is written into the directory.
            var security = new DirectorySecurity();
            security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
            security.AddAccessRule(new FileSystemAccessRule(
                WindowsIdentity.GetCurrent().User,
                FileSystemRights.FullControl,
                InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
                PropagationFlags.None,
                AccessControlType.Allow));

            _dir.SetAccessControl(security);
        }
    }

    // Not named Path - a member called Path would shadow System.IO.Path
    // for every line in this class.
    public string DirectoryPath => _dir.FullName;

    public string CreateFile(string name, string content)
    {
        // GetFileName strips any directory part, so a caller-supplied name
        // like "../escape.txt" cannot write outside the temp directory.
        string filePath = Path.Combine(_dir.FullName, Path.GetFileName(name));

        File.WriteAllText(filePath, content);

        // No per-file permission call: the files inherit the directory's ACL on
        // Windows, and on Unix 0700 on the directory already denies other users.
        return filePath;
    }

    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }

        _disposed = true;

        try
        {
            _dir.Delete(recursive: true);
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"Failed to delete temp directory: {ex}");
        }
    }
}

// Usage
using (var tempDir = new TempDirectoryManager())
{
    string file1 = tempDir.CreateFile("data1.txt", "content1");
    string file2 = tempDir.CreateFile("data2.txt", "content2");

    BatchProcess(file1, file2);
}
// Directory and all files auto-deleted

Why this works:

  • The directory is the security boundary, not each file: once the directory denies other users, everything created inside it is covered. That removes the per-file permission call, and with it the chance of forgetting one
  • No permission window on Unix: Directory.CreateTempSubdirectory() uses mkdtemp, which applies 0700 as part of the creation. The Windows branch cannot do this in one call, so it resets the ACL before any file is written rather than after
  • Unguessable directory name: CreateTempSubdirectory() names the directory with the same generator as Path.GetRandomFileName(), so the prefix is the only predictable part
  • Caller-supplied names cannot escape: Path.GetFileName() reduces ../escape.txt to escape.txt before it is joined
  • Cleanup is one operation: Delete(recursive: true) removes the directory and everything in it, so no file can be missed
  • Disposal is idempotent: the _disposed guard makes a second Dispose() a no-op rather than a second delete attempt

Version note: Directory.CreateTempSubdirectory() is .NET 7+. On .NET 6, create the directory with Directory.CreateDirectory() under a Path.GetRandomFileName() name and set the ACL immediately afterwards, accepting the brief window on Unix.

When to use: Batch processing scenarios requiring multiple related temp files - all are isolated in one secure directory and cleaned up atomically. Ideal for complex operations that generate multiple intermediate files.

Considerations

Whether a temp file finding is material depends on what is in the file and who shares the machine. A temp file holding a resized image on a single-tenant container is not the same finding as one holding a decrypted document on a shared build agent or a terminal server. The question to answer is not "is the name random" but "which other accounts can read this path, and does the content matter to them". Where the answer is that the process is the only thing on the host and the content is not sensitive, recording the finding as a false positive with that reasoning is a legitimate outcome.

The runtime version changes the verdict on Path.GetTempFileName(). On .NET Framework and .NET 7 and earlier it is a real weakness on Windows - sequential names from a 65,536-entry space. On .NET 8 and later the same call is defensible: roughly 2^30 randomly chosen names, no exhaustion limit, and the file is created atomically. Check the target framework before treating the call as the defect, because a scanner rule keyed on the method name cannot.

On Unix the platform default runs the other way, and it is the reason to prefer a directory. GetTempFileName() goes through mkstemps and lands on 0600. Path.GetRandomFileName() followed by a plain new FileStream(path, FileMode.CreateNew) requests 0666, which the umask usually reduces to 0644 - world-readable. So the "more secure" choice is the weaker one on Linux and macOS unless the creation also sets UnixCreateMode, which needs .NET 7. If a single call has to be right on both platforms and you are on .NET 6, create the file inside an owner-only directory instead of trying to get the file mode right.

Entropy is rarely the parameter worth tuning. The examples here range from 30 bits to 128, and the difference between them almost never decides an attack - the file usually exists for milliseconds, and an attacker who can watch the temp directory does not need to guess. Spend the effort on the creation flags and the permissions, which is where the exploitable mistakes are. The 128-bit example is here for environments whose compliance baseline names a key length, not because 55 bits is short.

FileOptions.DeleteOnClose is a cleanup mechanism, not an access control. It shortens how long the data is on disk, which reduces exposure after the fact. It does nothing about who can read the file while it is open, and it actively breaks any workflow that passes the path to another process.

Common Pitfalls

  • Swapping Path.GetTempFileName() for Path.GetRandomFileName() and nothing else: This is the most common way to make the problem worse rather than better. GetTempFileName() creates the file; GetRandomFileName() returns a string and creates nothing. If the replacement is followed by File.WriteAllText(), File.Create() or FileMode.OpenOrCreate, the code has traded a claimed name for an unclaimed one and reintroduced the race the original did not have. The random name is only half the fix - FileMode.CreateNew is the other half.
  • Setting restrictive ACLs after writing the file, not before: Calling File.WriteAllText() first and then SetAccessControl() leaves a window - however brief - during which the file exists with default, sometimes inheritable or world-readable, permissions while it may already contain sensitive data.
  • Relying on FileOptions.DeleteOnClose while also handing the path to an external process: The OS deletes the file the moment the FileStream handle closes. If that path is passed to a child process or another API that opens its own handle asynchronously, that consumer can hit "file not found" or read a truncated file - defeating the reason the temp file existed.

Additional Resources