Drupal private file serving with private:// stream wrapper
Store sensitive uploads outside docroot using private:// and force downloads through Drupal's private file controller so access checks run before bytes are streamed.
31 Mar 2026, 04:26 UTC

Problem: sensitive uploads must not be directly reachable by the web server
When a site stores invoices, identity documents, or draft media, a direct file URL must not leak them to anyone who guesses the path. The useful takeaway is to keep those files outside the document root using Drupal's private:// stream wrapper and route every request through system/files/private/file.php so Drupal bootstraps and checks entity permissions before streaming any bytes.
Requirements
Private uploads need three guarantees: the web server cannot serve the file directory directly, Drupal controls every download through an access callback, and access decisions depend on the File entity and its parent content rather than operating-system permissions alone. Filenames themselves may contain PII, so error messages and logs must not expose physical paths or original names on failure.
Smallest suitable design with core
Drupal core provides the private:// stream wrapper. It maps to a directory configured in settings.php, conventionally outside the web root. Files written with scheme private are stored there and referenced as private://filename. Downloads go through the core private file controller, which loads the file by ID and runs access checks before streaming.
Configure the private path
In settings.php, set the private path to a location not under docroot. Run this edit as the site owner or deployment user with write permission to settings.php.
// settings.php
// Environment-specific absolute path outside the document root.
// Do not commit this value to version control.
$settings['file_private_path'] = '/var/www/example-com/private';
Risk: configuration import or environment drift can change the path. Keep it environment-specific and verify it after deployments.
Enforce access before streaming
Use File entities with scheme private. The core controller at system/files/private/file.php bootstraps Drupal, loads the File entity by fid, and checks access. For node-attached files, grant access only when the current user can view the parent node. For custom entities, implement hook_file_download or the entity access handler so the controller receives a correct access result before bytes are sent.
Do not rely on .htaccess rules or filesystem permissions alone. The web server process must not have a document root, Alias, or symlink pointing to the private directory.
Trust and data boundaries
The boundary sits between the public document root and the private directory. The web server is untrusted for authorization; the PHP process is trusted to enforce access. Data flow: browser requests system/files/private/file.php?fid=123, Drupal bootstraps, loads the File entity, checks node or entity access, then streams from the private:// path. On denial, the controller returns 403 without revealing the physical path.
Operational checks
- Inspect settings.php as the site owner and confirm $settings['file_private_path'] points outside docroot.
- Visit /admin/config/media/file-system as an administrator and confirm the private file system path matches settings.php and is writable by the web server user.
- Place a test file with scheme private, generate its private URL, and request it as an anonymous user. Expect HTTP 403.
- Request the same URL as a user with permission to view the parent content. Expect HTTP 200 and the file contents.
- Review the web server configuration. No DocumentRoot, Alias, or location block should serve the private directory directly.
- Check the file_usage table for orphaned records and review web server access logs for direct requests to the private path.
Limitation: private file delivery blocks a PHP-FPM worker for the full download duration. Large files increase concurrency pressure, and core does not optimize range requests or resumable downloads.
Failure modes
- Private path inside docroot or exposed by Alias allows direct download bypassing Drupal.
- Symlink from public path to private directory bypasses access checks.
- File IDs are enumerable; if access callbacks do not check the parent entity, authorized users can guess other files.
- Error messages or logs that include filenames or paths can leak PII on failed access.
- Configuration import moving the private path can orphan existing files on disk.
When to change the design
Move away from core private serving when requirements include CDN edge delivery, multi-site shared storage, or signed URLs with short expiry. In those cases, use object storage with a private bucket and let Drupal issue time-limited tokens. The application then validates the token and redirects or proxies the object storage URL. This adds complexity around token revocation and audit logging.
Practical verification
Before relying on private:// for sensitive data, confirm the private path is outside docroot, the web server cannot list or serve it, and an anonymous request to a private file URL returns 403 while an authorized request returns 200. Repeat this check after any configuration import or server change.