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_token in 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:

  1. Traefik terminates SSL and forwards requests to Apache on port 80 over plain HTTP.
  2. PHP/Drupal sees the connection as HTTP (not HTTPS) unless explicitly told otherwise via X-Forwarded-Proto.
  3. Drupal's SessionConfiguration::getOptions() sets cookie_secure based on $request->isSecure(). Without proper reverse proxy configuration this returns false.
  4. Even with correct reverse proxy settings, PHP 8.3's session.cookie_samesite ini setting does not work when set via FPM pool config (php_admin_value) — it is silently ignored.
  5. Symfony's NativeSessionStorage calls ini_set('session.cookie_samesite', ...) from the services.yml values, but exits early if the session has already started or headers have been sent.
  6. The login Set-Cookie response therefore lacks SameSite=None:
    Set-Cookie: SSESS...=...; path=/; secure; HttpOnly
  7. Modern browsers (Chrome 80+, Firefox 79+, Safari 13.1+) treat cookies without an explicit SameSite attribute as SameSite=Lax by default.
  8. SameSite=Lax blocks cookies on cross-context POST requests — exactly what a DropzoneJS media library upload is (a POST originating from a modal context).
  9. 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: true

5. 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-headers

Verification

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_samesite via ini_set() in all code paths, and FPM pool config php_admin_value for this setting is silently ignored
  • Drupal's SessionConfiguration does not explicitly include cookie_samesite in 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+)

Related

Comments

heneryh created an issue. See original summary.

heneryh’s picture

Cross 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

heneryh’s picture

Issue summary: View changes