Credit to Claude after three days of debugging...
Do with this info what you see best.
Fixing DropzoneJS 403 on Media Library Uploads Behind a Reverse Proxy
Environment: Drupal 10.x (Varbase 10.0.x) · Apache 2.4 · PHP 8.3-FPM · Traefik reverse proxy (SSL termination) · MariaDB
The Symptom
Media Library file uploads via the DropzoneJS widget fail with a 403 Forbidden response on POST /dropzonejs/upload?token=.... The error appears in Drupal's watchdog as:
Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
The upload works fine in local development but fails in any production environment where HTTPS is terminated at a reverse proxy (Traefik, nginx, HAProxy, Cloudflare, etc.) before reaching Apache.
What We Ruled Out
After several days of investigation, the following were not the root cause:
- CSRF token validation failure — disabling
_csrf_tokenin routing.yml still 403'd - Missing "dropzone upload files" permission — permission was correctly assigned
- Traefik middleware blocking the request — no auth middleware on Drupal routers
- Apache LimitRequestBody or POST restriction — no such limits configured
- PHP upload size limits — set to 3400M, test file was 5KB
- Security Kit module — CSRF origin check disabled, still 403'd
- Session file permissions — Drupal uses database sessions, not file sessions
The Actual Root Cause
The browser was silently dropping the session cookie on upload POST requests due to a missing SameSite=None attribute. Here is the chain of events:
- Traefik terminates SSL and forwards requests to Apache on port 80 over plain HTTP.
- PHP/Drupal sees the connection as HTTP (not HTTPS) unless explicitly told otherwise via
X-Forwarded-Proto. - Drupal's
SessionConfiguration::getOptions()setscookie_securebased on$request->isSecure(). Without proper reverse proxy configuration this returnsfalse. - Even with correct reverse proxy settings, PHP 8.3's
session.cookie_samesiteini setting does not work when set via FPM pool config (php_admin_value) — it is silently ignored. - Symfony's
NativeSessionStoragecallsini_set('session.cookie_samesite', ...)from theservices.ymlvalues, but exits early if the session has already started or headers have been sent. - The login
Set-Cookieresponse therefore lacksSameSite=None:Set-Cookie: SSESS...=...; path=/; secure; HttpOnly - Modern browsers (Chrome 80+, Firefox 79+, Safari 13.1+) treat cookies without an explicit
SameSiteattribute asSameSite=Laxby default. SameSite=Laxblocks cookies on cross-context POST requests — exactly what a DropzoneJS media library upload is (a POST originating from a modal context).- The upload request arrives at Drupal with no session cookie. Drupal sees an anonymous user and returns 403.
Why the session in the database doesn't match the browser cookie: When the upload request arrives without the session cookie, Drupal creates a new anonymous session, stores it in the database, then immediately destroys it (no session data to keep for an anonymous user). The browser's authenticated session remains in the database untouched. The two never match.
The Fix
1. Configure Apache to trust Traefik's forwarded headers
In /etc/apache2/sites-available/your-drupal-site.conf:
<VirtualHost *:80>
RemoteIPHeader X-Forwarded-For
RemoteIPTrustedProxy 10.10.1.53 # your Traefik IP
SetEnvIf X-Forwarded-Proto "https" HTTPS=on
# THE CRITICAL FIX — appends SameSite=None; Secure to every Set-Cookie header
Header always edit Set-Cookie ^(.*)$ "$1; SameSite=None; Secure"
</VirtualHost>Enable required modules and reload:
sudo a2enmod remoteip headers sudo a2ensite your-drupal-site.conf sudo systemctl reload apache2
The Header always edit directive is the critical fix. It appends SameSite=None; Secure to every Set-Cookie header Apache sends, regardless of what PHP sets. This is reliable across all PHP versions and survives Drupal/PHP upgrades.
2. Configure PHP-FPM pool
In /etc/php/8.3/fpm/pool.d/www.conf, add to the [www] section:
php_admin_flag[session.cookie_secure] = On php_admin_flag[session.cookie_httponly] = On php_admin_value[session.cookie_samesite] = None
Note: In PHP 8.3 the cookie_samesite pool setting is silently ignored (see bug section below), but include it for forward compatibility with PHP 8.4+.
3. Configure Drupal settings.php
// Ensure PHP sees the connection as HTTPS
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
$settings['reverse_proxy'] = TRUE;
$settings['reverse_proxy_addresses'] = ['10.10.1.53']; // your Traefik IP
$settings['reverse_proxy_trusted_headers'] =
\Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_FOR |
\Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_HOST |
\Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PORT |
\Symfony\Component\HttpFoundation\Request::HEADER_X_FORWARDED_PROTO;4. Configure Drupal services.yml
parameters:
session.storage.options:
cookie_samesite: None
cookie_secure: true5. Configure Traefik dynamic.yaml
Add a middleware to explicitly set the forwarded proto header on all Drupal routers:
http:
middlewares:
drupal-headers:
headers:
customRequestHeaders:
X-Forwarded-Proto: "https"
routers:
your-drupal-router:
rule: "Host(`your-site.example.com`)"
service: drupal-service
middlewares:
- drupal-headersVerification
After applying the fix, the login Set-Cookie response header should include SameSite=None; Secure. Check with browser DevTools → Network tab → login request → Response Headers:
Set-Cookie: SSESS...=...; path=/; domain=.your-site.example.com; secure; HttpOnly; SameSite=None; Secure
You can also verify the reverse proxy is being detected correctly from Drush:
drush php-eval " \$request = \Drupal::request(); echo 'isSecure: ' . (\$request->isSecure() ? 'TRUE' : 'FALSE') . PHP_EOL; echo 'Client IP: ' . \$request->getClientIp() . PHP_EOL; "
Expected output: isSecure: TRUE and your real client IP (not the Traefik IP).
Why This Is a Bug (Not Just Misconfiguration)
Drupal's default.services.yml documents cookie_samesite as a supported session option:
# Set the SameSite cookie attribute: 'None', 'Lax', or 'Strict'. # If set, this value will override the server value. cookie_samesite: Lax
However, in PHP 8.3, the value set in services.yml silently fails to apply to the actual Set-Cookie header. This is a combination of three issues:
- Symfony's
NativeSessionStorage::setOptions()exits early if the session has already started or headers have been sent - PHP 8.3 does not properly support
session.cookie_samesiteviaini_set()in all code paths, and FPM pool configphp_admin_valuefor this setting is silently ignored - Drupal's
SessionConfigurationdoes not explicitly includecookie_samesitein the options array it builds for the session handler
The result is that cookie_samesite: None in services.yml appears to be configured correctly but has no effect on the outgoing Set-Cookie header. The Apache Header always edit workaround is reliable and production-safe, but the underlying behaviour should be considered a bug against Drupal's session configuration layer.
Affected Versions
- Drupal 10.x (confirmed on Varbase 10.0.x)
- PHP 8.3-FPM
- Apache 2.4
- Any reverse proxy terminating SSL (Traefik, nginx, HAProxy, Cloudflare, etc.)
- Browsers enforcing SameSite=Lax by default (Chrome 80+, Firefox 79+, Safari 13.1+)
Comments
Comment #2
heneryh commentedCross posting to symfony:
The Apache Header always edit fix is reliable and production-safe, but the underlying issue is worth reporting to the Drupal and Symfony issue queues.
https://www.drupal.org/project/symfony/issues/3575220
Comment #3
heneryh commented