SparkBox/Guides/Paperless-ngx 403

Paperless-ngx 403 Forbidden Behind a Reverse Proxy: The Real Fix

You log in to Paperless-ngx, or try to upload a document, and instead of the dashboard you get a plain "403 Forbidden" page. It looks like a permissions bug, but it isn't one — Paperless simply doesn't know it's allowed to trust the domain you're typing into your browser, and Django's CSRF protection slams the door shut.

SparkBox dashboard home with app launcher tiles and system stats
SparkBox dashboard home with app launcher tiles and system stats

Rather not hand-edit env files every time you add a domain? SparkBox sets the trusted-URL config for you when you point Paperless behind a proxy, and refuses to overwrite an existing install if it finds one already sitting there. See the walkthrough →

The 10-second version: Set the PAPERLESS_URL environment variable to the exact address you use to reach Paperless (with https://, no trailing slash), then restart the container. That single value tells Django which domain to trust for form submissions.

Why this shows up as "403," not "permission denied"

Paperless-ngx is a Django application, and Django ships with built-in Cross-Site Request Forgery (CSRF) protection. In plain terms: whenever you submit a form — logging in, uploading a document, changing a setting — the browser sends along a hidden token, and Django checks that the request's origin (the domain in the URL) is one it explicitly trusts. If it isn't on that trust list, Django refuses the request outright and returns a 403 Forbidden.

Sonarr's Media Management settings — root folders and file naming
Sonarr's Media Management settings — root folders and file naming
Sonarr series management on a SparkBox server
Sonarr series management on a SparkBox server

This has nothing to do with file ownership, container users, or folder permissions on disk. It's purely about the domain name in your browser bar not matching anything Paperless was told to expect. It almost always appears the moment you put a reverse proxy — Nginx, Caddy, Traefik, Nginx Proxy Manager, Cloudflare Tunnel, whatever — in front of a Paperless-ngx instance that was previously only reached by a bare IP and port.

Cause: Paperless has no way to know your domain is legitimate

Out of the box, Paperless-ngx doesn't automatically trust whatever hostname a proxy forwards to it. Unless you explicitly tell it "this domain is mine, allow it," every request that arrives with that Host header gets treated as a potential forgery and blocked. This is a safety default, not a bug — it just needs one line of configuration to switch off for your own domain.

Step 1: Confirm the exact address you use

Look at your browser's address bar when you access Paperless. Note the full scheme and domain — for example https://docs.example.com — including the port if you use a non-standard one.

Step 2: Set PAPERLESS_URL

In current Paperless-ngx releases (verified against the settings logic in paperless-ngx 2.20.15), the environment variable PAPERLESS_URL is the supported way to configure this. Add it to your .env file or directly under environment: in your compose file:

PAPERLESS_URL=https://docs.example.com

If you reach Paperless from more than one domain — say a local hostname and a public one — Paperless_URL accepts multiple values so both are trusted at once. Internally, this setting is translated into Django's CSRF_TRUSTED_ORIGINS, which is the actual mechanism doing the trusting.

Step 3: Restart the stack

Environment variable changes don't apply to a running container — Paperless only reads them at startup. Recreate the container so the new value takes effect:

docker compose down
docker compose up -d

A plain restart of an already-running container sometimes isn't enough if the environment was only added to the compose file and not yet picked up; a full down/up (or an equivalent recreate) is the safest way to be sure the new value is actually loaded.

Gotcha: the value must include the scheme (https:// or http://) and must not have a trailing slash. It also has to match what's in the browser bar exactly — if you access Paperless on port 8443 but leave the port off this value, Django still won't trust it.

If you're on an older version

Some older setup guides reference PAPERLESS_CSRF_TRUSTED_ORIGINS directly instead of PAPERLESS_URL. Functionally it's the same underlying Django setting — PAPERLESS_URL is simply the newer, friendlier way to set it in one place. If your version doesn't recognize PAPERLESS_URL, use the older variable name with the same value, following the comma-separated format shown in your version's documentation for more than one origin.

SparkBox Updates page with one-button updating
SparkBox Updates page with one-button updating

Double-check the proxy is forwarding headers correctly

Once PAPERLESS_URL is set and the container has restarted, try logging in again. If you still get a 403, revisit the value one more time for typos — a mismatched port or a missing https:// is the most common reason the fix doesn't take on the first try. There's no partial credit here: the trusted-origin match is exact.

Frequently asked

Why does Paperless-ngx say 403 instead of a permission error?

403 Forbidden here comes from Django's CSRF protection, not from your operating system's file permissions. Django blocks the request because it doesn't recognize the domain you're using as one it trusts, so it refuses to process the form submission — login, upload, anything that POSTs data.

What's the difference between PAPERLESS_URL and PAPERLESS_CSRF_TRUSTED_ORIGINS?

PAPERLESS_URL is the newer, simpler environment variable. Set it once to the exact address you use to reach Paperless, and current versions translate it into Django's CSRF_TRUSTED_ORIGINS setting internally. Older guides reference PAPERLESS_CSRF_TRUSTED_ORIGINS directly, which still works but needs a comma-separated list if you use more than one domain.

Do I need this if I access Paperless only over a local IP?

If you're hitting Paperless directly by IP and port with no reverse proxy in front of it, you usually won't see this. The 403 shows up specifically when a proxy sits between your browser and the container and forwards a Host header Paperless doesn't recognize as trusted.

Will this fix also cover a custom subpath or multiple domains?

Yes. PAPERLESS_URL accepts multiple origins when you need to reach the app from more than one domain, and the value should include the scheme (https://) and match exactly what's in your browser's address bar, including any port.

Skip the env-file tuning entirely

SparkBox configures the trusted-URL setting for Paperless-ngx automatically when you set it up behind a proxy, and it fingerprints existing installs first — if it finds real markers of a prior install (data folders, state, a dangling symlink from an old setup), it aborts loudly with repair instructions instead of quietly overwriting your data.

Get SparkBox → Or read the media-server walkthrough →

About this guide: Written and tested by the SparkBox team on a UGREEN DXP4800 Plus and a $7/month Hostinger VPS, both running SparkBox 1.6.503. The causes above are the real ones we've diagnosed in d/sparkbox. If something doesn't match, tell us on YouTube.