unnamed

Why Invoice Ninja Kept Logging Me Out — And How CrowdSec Was the Culprit

TL;DR: I set up Invoice Ninja for my business invoicing needs. After logging in, refreshing the page would immediately kick me back to the login screen. After a deep troubleshooting session, the culprit turned out to be a CrowdSec AppSec rule designed for Langflow AI — misfiring on Invoice Ninja’s /api/v1/refresh endpoint. Here’s exactly how I found it and fixed it.

Background: Self-Hosting My Own Invoicing

As a freelancer, I needed a clean invoicing solution. Rather than paying monthly fees for hosted software, I decided to self-host Invoice Ninja — an open-source invoicing platform built on Laravel. It supports multi-currency, client portals, recurring invoices, and everything else a small business needs.

My self-hosting stack runs on a Proxmox server with Docker managed via Dockge. All services sit behind NPMPlus (an enhanced fork of Nginx Proxy Manager) with CrowdSec integrated for automated threat detection. If you want to know more about that setup, I wrote a full walkthrough here:

👉 Migrating from Nginx Proxy Manager to NPMPlus with CrowdSec: A Complete Walkthrough

The Problem: Logged Out on Every Refresh

After getting Invoice Ninja up and running, I noticed something immediately annoying — every time I refreshed the page, I’d be kicked back to the login screen with this error:

The error that appeared on every page refresh

My setup is: Cloudflare (DNS proxy) → NPMPlus (reverse proxy) → Docker (Invoice Ninja). This triple-proxy chain is a common source of session issues in Laravel apps, so I started there.

What I Tried First (The Usual Suspects)

1. Environment Variables

For Laravel apps running behind a reverse proxy in Docker, there are several important .env settings. I verified and added all of these:

IS_DOCKER=true
SESSION_DRIVER=file
TRUSTED_PROXIES=*
REQUIRE_HTTPS=true
SESSION_SECURE_COOKIE=true
SESSION_DOMAIN=invoices.yourdomain.com
SESSION_SAME_SITE=none
APP_URL=https://invoices.yourdomain.com

None of these fixed it.

2. Mounting the .env File Into the Container

Invoice Ninja’s Docker image uses env_file to inject variables, but Laravel also needs to read the .env file directly from disk for certain operations. I confirmed the container couldn’t find it via the logs:

production.ERROR: file_get_contents(/var/www/app/.env):
Failed to open stream: No such file or directory

The fix was adding a bind mount to the compose file:

volumes:
  - ./.env:/var/www/app/.env:ro   # Add this line
  - ./public:/var/www/app/public:rw,delegated
  - ./storage:/var/www/app/storage:rw,delegated

⚠️ Pitfall: This is easy to miss. Invoice Ninja’s Docker docs show env_file usage, but Laravel also needs the actual .env file present on disk inside the container. Without it, some operations silently fail.

Still logging me out on refresh.

3. Cloudflare SSL Mode

A classic cause of session issues is Cloudflare set to Flexible SSL mode. With Flexible, Cloudflare talks HTTP to your origin while the browser thinks it’s on HTTPS — so the secure cookie never matches. I confirmed mine was already set to Full (strict), so this wasn’t the issue.

4. Nginx Proxy Manager Headers

I also verified NPMPlus was correctly forwarding the required headers in the Advanced tab of the proxy host:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;

Still no luck.

Going Deeper: Checking the Logs

With all the obvious fixes exhausted, I dug into the actual Laravel logs:

docker exec -it ninja-app tail -f storage/logs/laravel.log

Nothing useful was appearing — the log wasn’t updating at all when I reproduced the error. I then checked the container’s stderr output instead:

docker logs -f ninja-app

All requests were returning HTTP 200. No errors. The server was completely happy — which meant the problem wasn’t server-side at all. Something was interfering between the browser and the app after the response was sent.

The Real Culprit: CrowdSec

While digging further, I checked the CrowdSec dashboard — and spotted something immediately:

CrowdSec was blocking my own IP every time Invoice Ninja tried to refresh the session

My own local IP address (192.168.x.x) was being flagged — repeatedly — by the scenario crowdsecurity/vpatch-CVE-2025-34291, targeting the URI /api/v1/refresh.

Invoice Ninja’s Flutter frontend makes a burst of API calls when you load a page — including a call to /api/v1/refresh to check for data updates. CrowdSec was seeing this, matching it against a security rule, and issuing a block. Every page refresh triggered the pattern, causing a 403 Forbidden response, which Laravel interpreted as an authentication failure and redirected back to the login page.

What Is CVE-2025-34291?

Here’s the ironic part: CVE-2025-34291 has nothing to do with Invoice Ninja. It’s a CORS misconfiguration vulnerability in Langflow — an AI workflow platform. The vulnerable endpoint in Langflow happens to also be called /api/v1/refresh.

CrowdSec’s AppSec rule for this CVE matches any request where:

  • The URI contains /api/v1/refresh, AND
  • The request includes an Origin header containing https://

Invoice Ninja’s app sends exactly this — a refresh call to /api/v1/refresh with an https:// origin header. It’s a textbook false positive.

The Fix: Modifying the AppSec Rule

Rather than disabling the rule entirely (which would leave real Langflow instances unprotected), I modified it to exclude my Invoice Ninja domain. The fix adds a third condition to the rule’s and logic: only trigger if the host is NOT my Invoice Ninja domain.

Step 1: Find the Rule File

docker exec -it crowdsec find / -name "vpatch-CVE-2025-34291.yaml" 2>/dev/null
# Result: /etc/crowdsec/hub/appsec-rules/crowdsecurity/vpatch-CVE-2025-34291.yaml

Step 2: Copy It to Your Host Config Directory

Copying the file to your host config directory means it overrides the hub version, and your changes survive container restarts and image updates.

docker cp crowdsec:/etc/crowdsec/hub/appsec-rules/crowdsecurity/vpatch-CVE-2025-34291.yaml \
  /opt/stacks/npmplus/crowdsec/conf/appsec-rules/vpatch-CVE-2025-34291.yaml

Step 3: Edit the Rule

Open the file and add a third condition to the and block, right before the labels section. Here’s the complete modified rule — replace invoices.yourdomain.com with your own Invoice Ninja domain:

## autogenerated on 2026-01-14 14:58:09
name: crowdsecurity/vpatch-CVE-2025-34291
description: 'Detects CORS misconfiguration in Langflow AI allowing any origin with credentials (CVE-2025-34291)'
rules:
  - and:
      - zones:
          - URI
        transform:
          - lowercase
        match:
          type: contains
          value: /api/v1/refresh
      - zones:
          - HEADERS
        variables:
          - origin
        transform:
          - lowercase
        match:
          type: contains
          value: https://
      - zones:
          - HEADERS
        variables:
          - host
        transform:
          - lowercase
        match:
          type: equals
          value: invoices.yourdomain.com   # <-- Replace with your Invoice Ninja domain
        negate: true
labels:
  type: exploit
  service: http
  confidence: 3
  spoofable: 0
  behavior: 'http:exploit'
  label: 'Langflow AI - CORS Misconfiguration'
  classification:
    - cve.CVE-2025-34291
    - attack.T1190
    - cwe.CWE-942

Step 4: Restart CrowdSec

docker restart crowdsec

💡 Tip: Files placed in your host config directory (/opt/stacks/npmplus/crowdsec/conf/appsec-rules/) take precedence over the hub versions inside the container. This means your customised rule persists through updates.

Verifying the Fix

After restarting CrowdSec, I logged into Invoice Ninja and refreshed the page. No logout. Refreshed again. Still logged in. The rule is still active for everything else:

docker exec -it crowdsec cscli scenarios list | grep vpatch
# crowdsecurity/appsec-vpatch   ✔️  enabled

Lessons Learned

  • Check your security stack early. I spent a long time troubleshooting .env settings and proxy headers before thinking to look at CrowdSec. It should have been one of the first things I checked.
  • Security tools can cause mysterious application behaviour. CrowdSec was doing exactly what it was configured to do — the rule was just too broad in its pattern matching.
  • False positives in AppSec rules are common. CVE rules that match on URL path patterns will inevitably catch legitimate apps that share similar endpoint naming conventions.
  • Targeted exclusions beat disabling rules. Instead of turning off the protection entirely, a host-based negate condition keeps the rule effective for its actual target while excluding your app.
  • docker logs -f is your friend. When Laravel logs show nothing, the container’s stderr output often tells a completely different story.

If you’re running Invoice Ninja behind NPMPlus with CrowdSec and experiencing the same issue, I hope this saves you a few hours of head-scratching. The fix takes about two minutes once you know what you’re looking for.

Add a Comment

You must be logged in to post a comment