Persisting User-Supplied Files Safely with Foundation FileManager
Save caller-supplied text inside your app sandbox without exposing it to other apps or extensions, using Application Support, path validation, and complete file protection.
05 Dec 2025, 23:44 UTC

Problem and takeaway
You need to save text supplied by a caller to a file inside your app’s container. The file must not be readable or replaceable by other apps or app extensions. If you build the file path from raw user input, a name such as ../etc/passwd can write outside the sandbox. If you choose the wrong protection level, background tasks fail when the device is locked.
Takeaway: Resolve the Application Support directory through FileManager, reject file names containing path separators or .., write atomically with .completeFileProtection, and verify the write before returning success.
Requirements
Persist caller-supplied text to a file inside the app sandbox container. The file must remain inaccessible to other apps and app extensions. The write must either complete fully or leave no partial file behind. Treat every part of the caller-supplied file name as untrusted input.
Smallest suitable design
Use FileManager.default.urls(for:in:) to locate the Application Support directory, then append a validated file name and write with Data.write(to:options:).
import Foundation
enum StorageError: Error {
case directoryUnavailable
case unsafeFileName(String)
case writeFailed(Error)
}
func persistUserText(_ text: String, fileName: String) throws -> URL {
guard let applicationSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else {
throw StorageError.directoryUnavailable
}
// Trust boundary: never concatenate raw caller input into a path.
let pathSeparators = CharacterSet(charactersIn: "/")
guard fileName.rangeOfCharacter(from: pathSeparators) == nil,
!fileName.contains(".."),
!fileName.isEmpty else {
throw StorageError.unsafeFileName(fileName)
}
let fileURL = applicationSupport.appendingPathComponent(fileName)
// Confirm the resolved URL stays inside Application Support.
guard fileURL.path.hasPrefix(applicationSupport.path) else {
throw StorageError.unsafeFileName(fileName)
}
do {
try Data(text.utf8).write(
to: fileURL,
options: [.atomic, .completeFileProtection]
)
} catch {
throw StorageError.writeFailed(error)
}
return fileURL
}
urls(for:in:) returns an array; reading .first safely avoids a crash when the directory cannot be resolved. The .atomic option writes to a temporary file in the same directory and moves it into place, so readers never see a partially written file. .completeFileProtection encrypts the file at rest and makes it unreadable while the device is locked.
Trust and data boundaries
The trust boundary is between the caller and the file system. Treat the file name as untrusted even if the caller is authenticated within your app. Path separators (/) allow writing to arbitrary directories. .. components allow escaping the intended folder. Rejecting both before constructing the URL keeps the resolved path inside Application Support.
The sandbox boundary means other apps cannot reach your container by default. App extensions share the container with the host app but run as separate processes. If an extension needs to read the file, it can access the same container path, but background execution may still be blocked by file protection.
Operational checks
- Confirm the directory exists before writing: check the array returned by
urls(for:in:)rather than force-unwrapping it. - Catch
CocoaError.fileWriteandCocoaError.fileNoSuchFilefrom the write call and surface a recoverable message instead of crashing. - Check available disk space with
URL.resourceValues(forKeys: [.volumeAvailableCapacityKey])when writing large caller-supplied payloads. - After the write, call
FileManager.default.fileExists(atPath:)only as a secondary sanity check; the write method itself throws on failure.
Failure modes
- An atomic write leaves a temporary file in Application Support if the process is terminated between creating the temporary file and renaming it. Clean up orphaned temporary files on launch if your design stores many short-lived files.
.completeFileProtectiondenies read access while the device is locked. Silent push handlers, background fetch, and any background task that runs after lock will fail to read the file.- If the user has not set a device passcode, complete file protection may not encrypt the file as expected on some OS versions.
Conditions that change the design
| Condition | Design change | Trade-off |
|---|---|---|
| App extension or background process must read the file while the host is locked | Move storage to an app group shared container or lower protection to .completeFileProtectionUntilFirstUserAuthentication | Wider trust boundary; more processes can reach the file |
| Multiple processes write the same file concurrently | Use NSFileCoordinator for file coordination | Added complexity; potential serialization delays |
| File must be readable immediately after boot, before first unlock | Drop .completeFileProtection or use .completeFileProtectionUntilFirstUserAuthentication | File is less protected while the device is locked before first authentication |
Practical verification
- Print
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)on macOS or iOS and confirm the path starts with your app container identifier. - Write a protected file, lock the device or simulator, then attempt a background read. The read should fail until the device is unlocked.
- Pass a file name such as
../etc/passwdto the writer and assert that the resolved file URL remains inside the Application Support directory and the function throws.
Limitations
This pattern applies to Apple platforms using Foundation in Swift or Objective-C. It does not cover file sharing with the user through the Files app, iCloud sync, or encrypted databases. Behavior in the simulator can differ from a physical device, especially around sandbox enforcement and passcode-gated protection. Verify on a device with a passcode set before shipping.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.