Tech Digest

Guide

Paperless-ngx "CSRF verification failed. Request aborted." behind a reverse proxy

Django compares the address your browser came from with the address it thinks it is serving, and behind an HTTPS proxy those two differ. We ran Paperless-ngx 3.4.2 behind Caddy, Traefik and Nginx Proxy Manager and tried each variable alone to see which ones clear the 403.

Last reviewed

How do you fix Paperless-ngx "CSRF verification failed. Request aborted." behind a reverse proxy?

Set PAPERLESS_URL to the exact address in the browser bar: scheme and host, the port only if it is not 443, and no trailing slash, for example PAPERLESS_URL=https://paperless.example.com. Then recreate the container. Behind Caddy, Traefik and Nginx Proxy Manager, that one variable cleared the 403. ALLOWED_HOSTS, USE_X_FORWARD_HOST, USE_X_FORWARD_PORT and TRUSTED_PROXIES did nothing on their own. If the log says null does not match, the proxy is sending Referrer-Policy: no-referrer, and no Paperless setting fixes that.

Set PAPERLESS_URL to the exact address in your browser bar, then recreate the container. That one variable fixes the 403 behind Caddy, Traefik and Nginx Proxy Manager. Use the scheme and the host. Add the port only if the bar shows one. Leave off the trailing slash.

yaml
services:
  webserver:
    image: ghcr.io/paperless-ngx/paperless-ngx:latest
    environment:
      PAPERLESS_URL: https://paperless.example.com
bash
docker compose up -d webserver
docker compose logs webserver | grep -i forbidden

The second command shows the reason for each refused login. If the line says null does not match any trusted origins, skip to the section on Origin: null. That cause has nothing to do with the variable.

The terms this answer turns on#

CSRF stands for cross-site request forgery. It is an attack where another website makes your browser send a form to Paperless while you are logged in. Django, the framework Paperless is built on, blocks it on every form that changes something. The login form is one of them.

The block uses the Origin header. When a browser submits a form, it adds a line saying which site the page came from. An origin is three things: the scheme (https), the host (paperless.example.com) and the port. Browsers leave the port out when it is the default, 443 for https.

Django accepts the form only if that origin matches one it trusts. The Django settings reference says the Origin must "match the origin present in the Host header". Otherwise it must be listed in CSRF_TRUSTED_ORIGINS, a list of extra trusted origins.

PAPERLESS_URL is Paperless's shortcut. Its docs say it "can be used to set the three options below": ALLOWED_HOSTS, CORS_ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS. In the 3.4.2 source, it adds the whole URL to the trusted origins and the hostname to the allowed hosts.

Why the login fails only behind the proxy#

The catch is the scheme, not the host. Django builds the origin it expects from two parts. The host comes from the Host header. The scheme comes from whether Django itself thinks the request was secure. Its _origin_verified code writes it as "https" if request.is_secure() else "http".

Behind a proxy, your browser speaks HTTPS to the proxy. The proxy then speaks plain HTTP to Paperless on port 8000. So the browser sends Origin: https://paperless.example.com, while Django expects http://paperless.example.com. The host matches. The scheme does not. Django refuses the form and logs Origin checking failed.

On http://192.168.1.10:8000 there is no proxy, so both sides say http. That is why Paperless worked on the LAN and broke the day the proxy went in. In our test, a direct login on port 8000 worked with nothing set.

Two answers circulate, and both miss the scheme#

The first answer is the stack. The Paperless wiki itself says to "consider setting" USE_X_FORWARD_HOST, USE_X_FORWARD_PORT and PROXY_SSL_HEADER next to PAPERLESS_URL. The TrueNAS thread adds CSRF_TRUSTED_ORIGINS and TRUSTED_PROXIES, and ALLOWED_HOSTS sits beside them in the same docs section. The login then works, so the whole stack gets copied. But only one or two of those lines do the work. The rest change nothing for this error, and one of them carries a security warning in Django's docs.

The second answer says the settings are not needed "as long as the proxy server passes the right headers". It is half true. All three proxies we tested passed Host unchanged on port 443. All three still gave the 403 with nothing set, because the scheme differs. A proxy alone cannot fix this. Django has to be told the scheme, or told the origin.

So the fix has two forms. Tell Django the origin directly with PAPERLESS_URL. Or tell it the scheme with PROXY_SSL_HEADER and let the passed-through Host do the rest. The first is simpler and works in more setups, as the table shows.

What each variable did on its own, measured#

We ran Paperless-ngx 3.4.2 (Django 5.2.18) with SQLite and Redis. In front of it sat Caddy 2.11.7, Traefik 3.7.17 and Nginx Proxy Manager 2.16.0, each ending TLS and passing plain HTTP to port 8000. Each proxy used its stock forwarding with no extra headers. Headless Chromium 142 loaded the login page and submitted the form. Each variable was set alone, and the container was recreated each time.

Variable set aloneCaddy, port 443Traefik, port 443NPM, port 443Same proxies on port 8443
Nothing403403403403 on all three
PAPERLESS_URL=https://paperless.example.testLogin worksLogin worksLogin works403 on all three
PAPERLESS_URL with the port, ...test:8443not testednot testednot testedLogin works on all three
PAPERLESS_CSRF_TRUSTED_ORIGINS=https://...Login worksLogin worksLogin worksnot tested
PAPERLESS_ALLOWED_HOSTS403403403not tested
PAPERLESS_USE_X_FORWARD_HOST=true403403403not tested
PAPERLESS_USE_X_FORWARD_PORT=true403403403not tested
PAPERLESS_PROXY_SSL_HEADERLogin worksLogin worksLogin worksCaddy and Traefik work, NPM 403
PAPERLESS_TRUSTED_PROXIES403403403not tested

Every 403 logged the same line, with the browser's own origin in it. ALLOWED_HOSTS decides which hostnames Paperless will answer at all. It plays no part in the origin check. The two forwarded-header switches change where Django reads the host. The host was never the problem. TRUSTED_PROXIES is about the client IP address, for logs and login rate limits. The docs tie it to tools like fail2ban.

A worked case: NPM on port 8443#

Say your router forwards outside port 8443 to Nginx Proxy Manager. You open https://paperless.example.com:8443. You already set PAPERLESS_PROXY_SSL_HEADER, because a thread said so, and the login still fails.

Walk the request through. The browser's Origin includes the port, because 8443 is not the default: https://paperless.example.com:8443. NPM's proxy.conf sends proxy_set_header Host $host, and nginx's $host holds the name without the port. So Paperless receives Host: paperless.example.com. The SSL header makes Django use https, so it expects https://paperless.example.com. The port is missing, and the check fails.

Caddy and Traefik pass the port through in Host, so the same setting works behind them. That matches the last column of the table. For NPM, set PAPERLESS_URL=https://paperless.example.com:8443. The trusted list then holds the exact origin the browser sends, whatever NPM does to Host.

The second cause: Origin null, which no setting fixes#

If the log says Origin checking failed - null does not match any trusted origins, PAPERLESS_URL is not the problem. The Paperless wiki traces it to a Referrer-Policy: no-referrer response header. The header may come from your proxy config or from a hardening template upstream of it.

The wiki explains why. Browsers apply the referrer policy to the Origin header on a same-origin form post. Under no-referrer, the browser sends the literal word null. We added that header in Caddy and in Traefik, with PAPERLESS_URL set correctly. Chromium sent Origin: null with no Referer, and both logins failed with the null line.

You cannot trust null instead. We set PAPERLESS_CSRF_TRUSTED_ORIGINS=null, and Paperless refused to start. Django's check 4_0.E001 says each value "must start with a scheme".

The fix belongs in the proxy. Paperless already sends Referrer-Policy: same-origin by itself, which is Django's default. Remove the override, or use the wiki's pattern: same-origin on the login and signup pages, no-referrer elsewhere. We ran the wiki's Caddy version, and the login worked while other pages still sent no-referrer.

One trap in NPM: we put add_header Referrer-Policy "no-referrer" always; in the proxy host's Advanced tab, and it never reached the browser. nginx's docs say add_header lines are inherited "if and only if" the inner level defines none. NPM's proxy.conf adds X-Served-By inside the location, so the server-level header is dropped. Check the response with curl -sI, not the config file.

Getting PAPERLESS_URL itself right#

  • No trailing slash. With https://paperless.example.test/, Paperless 3.4.2 did not start. Its startup check failed with corsheaders.E014, "should not have path".
  • The scheme must match the browser bar. http://paperless.example.test behind an HTTPS proxy still gave the 403.
  • No path. The docs say the value "cannot contain a path", even when Paperless runs under a subpath. That case uses PAPERLESS_FORCE_SCRIPT_NAME.
  • It does not lock the hostname. PAPERLESS_ALLOWED_HOSTS defaults to *, and the URL's hostname is added to that list. With only PAPERLESS_URL set, a request with Host: evil.example.net got 200. Set PAPERLESS_ALLOWED_HOSTS too if you want other names refused. With it set, the same request got 400.

Why not just set PROXY_SSL_HEADER#

It cleared the 403 on port 443 behind all three proxies. Django's docs attach a warning, though. Set it only if your proxy strips X-Forwarded-Proto from incoming requests and sets it itself.

We sent a request through each proxy carrying our own forwarded headers. Caddy and Traefik replaced them. NPM passed our X-Forwarded-Proto through unchanged, because its nginx.conf keeps an incoming http or https value. It did the same with our X-Forwarded-Host. So NPM does not meet Django's condition, and USE_X_FORWARD_HOST behind NPM would let a client choose the host Django sees. PAPERLESS_URL avoids both questions.

When this answer stops applying#

This was measured on Paperless-ngx 3.4.2 with Django 5.2.18. The proxies were Caddy 2.11.7, Traefik 3.7.17 and NPM 2.16.0, each ending TLS in front of plain HTTP. We tested the login form only, in Chromium. We did not test Apache, HAProxy, Cloudflare Tunnel, Firefox or Safari, or the mobile apps. The wiki says the null behavior is the same in Firefox. A 401 from the API behind forward auth, which the wiki also covers, is a different error. If your proxy rewrites Host to an internal name, add that name's origin with PAPERLESS_CSRF_TRUSTED_ORIGINS as well. And if the 403 continues with PAPERLESS_URL set and no null in the log, compare the logged origin with the variable letter by letter. Every failure in our runs came down to a difference there.

What to do next#

Read the Forbidden line in the log, put that exact origin in PAPERLESS_URL, and recreate the container. Docker Compose conventions shows the variable in a full Paperless compose file. If the 403 is gone but the proxy itself errors, Why Nginx Proxy Manager returns 502 Bad Gateway reads the log line for you. Reverse proxy and TLS covers the rest of the proxy setup. The same check-the-host pattern breaks other apps behind a proxy too, as Nextcloud "Access through untrusted domain" behind a reverse proxy and Home Assistant 400 Bad Request behind a reverse proxy show. If you are still deciding where your scans should live, Paperless-ngx vs Nextcloud weighs Paperless against filing them in Nextcloud, and the Paperless-ngx profile lists its other traps.

Questions#

Where do I find the reason for the Paperless CSRF 403?

In the container log: docker compose logs webserver | grep -i forbidden. Paperless logs one warning per refused login, such as Forbidden (Origin checking failed - https://paperless.example.com does not match any trusted origins.). The address in that line is what your browser sent. Copy it into PAPERLESS_URL exactly. If the address reads null, the cause is a Referrer-Policy header, not a missing setting.

Do I need PAPERLESS_CSRF_TRUSTED_ORIGINS as well as PAPERLESS_URL?

No. Paperless's settings code adds the value of PAPERLESS_URL to CSRF_TRUSTED_ORIGINS itself. In our test, PAPERLESS_URL alone and PAPERLESS_CSRF_TRUSTED_ORIGINS alone each cleared the 403 behind all three proxies. Set the second one only for an extra address, such as a second hostname. Both lists are combined.

Why does Paperless work on http://IP:8000 but not through the proxy?

On the direct address, the browser and Django agree: the page is http, and the host is what you typed. Our login on plain port 8000 worked with nothing set. Through an HTTPS proxy, the browser sends an https:// origin while Django still sees plain HTTP from the proxy. So it expects http:// and refuses the match.

Should PAPERLESS_URL include the port?

Only if the browser bar shows one. Browsers leave out 443 for https, so https://paperless.example.com is right on the standard port. If you reach Paperless at https://paperless.example.com:8443, the port is part of the origin and must be in PAPERLESS_URL. Without it, all three proxies gave the 403 on a non-standard port.

Can a trailing slash in PAPERLESS_URL cause the CSRF error?

No. It stops Paperless from starting at all. With PAPERLESS_URL=https://paperless.example.test/, the 3.4.2 container stopped during its startup checks with corsheaders.E014: the origin in CORS_ALLOWED_ORIGINS "should not have path". The docs say plainly: do not include a trailing slash.

Sources#

Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.