Forward Auth oder OIDC: Was authentik vor Paperless wirklich schützt

Dieselbe App, zweimal hinter authentik: einmal per Forward Auth, einmal per OIDC. Zehn reproduzierbare Szenarien zeigen, wo die beiden Wege sich unterscheiden.

Von 11 Min. Lesezeit

In meinem Homelab hängen mittlerweile gut zwei Dutzend Dienste an authentik, die meisten über Forward Auth: Caddy fragt bei jedem Request den Embedded Outpost, ob eine gültige Session existiert, und reicht den Benutzernamen als Header weiter an die Anwendung. Wie das eingerichtet ist, steht im Artikel zu Forward Auth mit Caddy. Fünf Dienste sprechen dagegen selbst OIDC mit authentik, darunter der Proxmox-Cluster und der DNS-Server, und eine Handvoll läuft ganz ohne authentik davor.

Diese Aufteilung ist über Monate gewachsen, meistens nach der Regel: Forward Auth, solange nichts dagegen spricht. Irgendwann stand bei mehreren Diensten der Vermerk „könnte auf echtes OIDC gehoben werden” auf meiner Liste, allen voran Paperless. Bevor ich das umsetze, wollte ich aber wissen, was sich dabei konkret ändert – nicht in der Theorie, sondern an derselben Anwendung, mit derselben Version und denselben Benutzern.

Also habe ich ein Lab gebaut, in dem Paperless-ngx dreimal läuft: einmal per Forward Auth, einmal per OIDC und einmal per OIDC ganz ohne Passwort-Login. Ein Skript geht zehn Szenarien durch und schreibt die Ergebnisse als JSON. Das komplette Lab mit Blueprints, Harness und Rohdaten liegt auf GitHub und startet mit docker compose up.

Zwei Wege zur selben Anmeldung

Bei Forward Auth weiss die Anwendung nichts von authentik. Caddy hält jeden Request zurück, bis der Outpost ihn freigibt, und kopiert danach Header wie X-Authentik-Username in die Anfrage. Paperless vertraut diesem Header über seine Remote-User-Unterstützung:

PAPERLESS_ENABLE_HTTP_REMOTE_USER: "true"
PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME: HTTP_X_AUTHENTIK_USERNAME

Bei OIDC spricht die Anwendung selbst mit authentik. Paperless nutzt dafür django-allauth: Der Browser landet beim Login auf authentik, kommt mit einem Code zurück, und Paperless tauscht diesen serverseitig gegen Tokens ein. Caddy leitet dabei nur noch weiter, geprüft wird nichts mehr.

Das Lab läuft mit denselben Versionen wie mein produktives Setup: authentik 2026.8.2, Paperless-ngx 2.20.15, Caddy 2.11.4. Die komplette authentik-Konfiguration steckt als Blueprints im Repository, ganz ohne Klicks in der Oberfläche. Zwei synthetische Benutzer sind angelegt: alice gehört zur Gruppe paperless-users, bob zu keiner.

Was gleich ist: die Policy-Prüfung

Ich hätte erwartet, dass sich die beiden Wege vor allem bei der Zugriffskontrolle unterscheiden. Stattdessen ist es dort, wo sie sich am wenigsten unterscheiden.

bob wird bei beiden Varianten an genau derselben Stelle abgewiesen: Die authentik-Seite „Permission denied” erscheint am Authorize-Endpunkt, noch bevor die Anwendung überhaupt etwas davon mitbekommt. Die Policy-Bindings einer Application gelten für einen Proxy-Provider genauso wie für einen OAuth2-Provider, weil beide intern über denselben Authorize-Weg laufen – der Forward-Auth-Outpost ist technisch selbst ein OAuth-Client.

alice kommt in beiden Fällen rein. Die Frage, wer eine Anwendung benutzen darf, beantwortet authentik also unabhängig vom gewählten Weg. Erst danach, bei dem was als Nächstes passiert, trennen sich die beiden Ansätze.

Was sich unterscheidet

SzenarioForward AuthOIDC
Aufruf ohne SessionRedirect zu authentikLogin-Seite von Paperless, mit Passwortfeld
Benutzer ohne Gruppeabgewiesen bei authentikabgewiesen bei authentik
Was Paperless über den Benutzer erfährtnur den BenutzernamenBenutzername, E-Mail, Name, Gruppen
API-Client mit Paperless-TokenRedirect zu authentikfunktioniert
Direkter Zugriff am Proxy vorbei mit gefälschtem HeaderSession als Adminwirkungslos
Benutzer in authentik deaktiviertnach wenigen Sekunden wieder bei authentikPaperless-Session bleibt gültig

Drei dieser Zeilen lohnen einen genaueren Blick.

Der Header ist nur so sicher wie der Weg um den Proxy herum

Forward Auth mit Header-Login beruht auf einer stillen Annahme: Jeder Request, der bei der Anwendung ankommt, ist vorher durch Caddy gelaufen. Solange das stimmt, hält die Annahme auch. Im Test blieb ein selbst mitgeschickter X-Authentik-Username: admin wirkungslos – ohne authentik-Session gibt es einen Redirect, mit Session überschreibt copy_headers den Wert wieder mit dem echten Benutzernamen. Die Schreibweise mit Unterstrich, X_Authentik_Username, verwirft Caddy schon von sich aus.

Anders sieht es aus, sobald jemand den Container direkt erreicht. Viele Compose-Dateien veröffentlichen den Port einer Anwendung trotzdem, oft aus Gewohnheit oder weil er zum Debuggen praktisch ist:

ports:
  - 8000:8000

Im Lab reicht dann ein einziger Request auf eine Seite der Oberfläche, zusammen mit dem gefälschten Header:

curl -c jar -H "X-Authentik-Username: admin" http://<host>:8000/
curl -b jar http://<host>:8000/api/users/

Der erste Aufruf liefert eine gültige Django-Session für den Benutzer admin, einen Superuser. Mit diesem Cookie antwortet danach die komplette API mit Admin-Rechten, auch ohne den Header. Direkt am Container funktioniert auch X_Authentik_Username, weil Django beide Schreibweisen intern auf denselben Namen abbildet. Aufschlussreich ist, wo Paperless tatsächlich eine Hürde eingebaut hat: Derselbe Header direkt an /api/ wird mit 401 abgewiesen, weil Remote-User für die API separat freigeschaltet werden müsste. Nur ist die Oberfläche eben nicht getrennt geschützt, und die Session, die sie ausstellt, gilt genauso für beide. Paperless nutzt dafür Djangos PersistentRemoteUserMiddleware, die eine einmal erzeugte Session behält, selbst wenn der Header später fehlt.

Dass ALLOWED_HOSTS hier bremst, sollte man nicht erwarten: Wer Paperless auch direkt unter dem Hostnamen oder der IP des Servers erreichen will, trägt diese dort ein – und schon steht der direkte Weg offen.

Schwieriger wird es, wenn ein anderer Rechner im Netz die API wirklich direkt braucht, etwa ein Skript mit Token. Den Port einfach zu schliessen, würde dann genau diesen Client kaputt machen. Sauberer ist ein zweiter, sehr kleiner Caddy im selben Stack, der den Port übernimmt und die Identitäts-Header in beiden Schreibweisen entfernt, bevor er weiterleitet:

:8000 {
	request_header -X-Authentik-*
	request_header -X_Authentik_*
	reverse_proxy paperless:8000
}

Paperless selbst bekommt dadurch keinen Host-Port mehr. Im Lab bleibt der Angriff über diesen Zugang wirkungslos, während Token-Abruf und API-Aufrufe weiterhin funktionieren. Die beiden request_header-Zeilen sind dabei kein optionales Extra: Ein Caddy mit nur reverse_proxy reicht X-Authentik-Username unverändert durch, und am Ende steht wieder eine Admin-Session.

Bei der OIDC-Variante läuft derselbe Angriff komplett ins Leere. Paperless ignoriert den Header, weil Remote-User dort gar nicht aktiviert ist, und leitet stattdessen auf die Login-Seite um. Darin liegt der eigentliche Sicherheitsgewinn von OIDC: Die Anwendung prüft die Identität selbst, statt einer Annahme über den Netzwerkweg zu vertrauen.

Apps und API-Clients kommen nicht durchs Gate

Mobile Apps und Skripte melden sich bei Paperless nicht über einen Browser an. Sie holen sich mit Benutzername und Passwort ein Token von /api/token/, oder bekommen eines fest hinterlegt, und schicken es bei jedem Aufruf mit. Das Lab bildet genau diesen API-Client nach, nicht eine konkrete App.

Bei Forward Auth scheitert schon der Token-Abruf: Er endet in einem Redirect zu authentik, der Client bekommt HTML statt JSON und kann damit nichts anfangen. Jeder weitere API-Aufruf mit gültigem Token läuft ins selbe Problem. Bei OIDC funktionieren dagegen beide Aufrufe, weil Caddy nichts prüft und Paperless das Token selbst validiert.

Das ist keine Eigenheit von Paperless, sondern trifft auf jede Anwendung mit eigenen Clients zu. In meinem Setup stehen Home Assistant, Vaultwarden und Gitea deshalb gar nicht hinter authentik: Deren Apps, Browser-Erweiterungen und Git-Clients melden sich selbst an. Einzelne Pfade lassen sich zwar vom Gate ausnehmen, wie im ersten Artikel gezeigt – bei einer API, die die ganze Anwendung abdeckt, bleibt vom Gate dann aber ohnehin kaum noch etwas übrig.

Sperren wirkt bei Forward Auth in Sekunden, bei OIDC nicht

Für dieses Szenario ist alice in beiden Varianten angemeldet. Danach wird sie über die authentik-API deaktiviert, und das Skript prüft im Zwei-Sekunden-Takt, was die bestehenden Sessions noch dürfen.

Bei Forward Auth landete der nächste Request in allen 20 Runden wieder bei authentik, spätestens nach zwei Sekunden, meistens sofort. Der Outpost prüft bei jedem Aufruf, ob die Session noch gilt, und die Deaktivierung beendet sie augenblicklich. Ein einzelner früherer Lauf hatte die Session nach fünf Sekunden noch als gültig gezeigt, liess sich danach aber nicht mehr reproduzieren. Bei OIDC dagegen antwortete Paperless über alle Runden hinweg weiter mit 200. Die Anwendung hat bei der Anmeldung eine eigene Session ausgestellt und fragt authentik seitdem nicht mehr. Ein neuer Login bei authentik scheitert zwar sofort, die laufende Session bleibt aber bestehen, bis sie von selbst abläuft.

Wie lange genau, steht im Quellcode von Paperless: PAPERLESS_SESSION_COOKIE_AGE hat als Standardwert drei Wochen. Wer bei OIDC-Anwendungen schnell sperren können muss, braucht also kürzere Sessions in der Anwendung selbst oder einen Logout-Mechanismus, den sie auch unterstützt. Im Homelab mit einer Familie als Benutzerkreis ist das selten ein Problem – bei einem Zugang, der im Ernstfall schnell dicht sein muss, sollte man es aber im Hinterkopf haben.

Was OIDC sonst mitbringt

Bei Forward Auth kennt Paperless nur den Benutzernamen. Der Account wird beim ersten Zugriff angelegt, ohne E-Mail, ohne Namen, ohne Gruppen. Rechte müssen danach in Paperless von Hand vergeben werden.

Bei OIDC übernimmt Paperless E-Mail, Namen und mit PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS auch Gruppen – allerdings nur solche, die in Paperless bereits existieren. Im Lab kam alice mit den Gruppen paperless-users und buchhaltung an, zugeordnet wurde ihr aber nur buchhaltung, weil nur diese Gruppe vorher in Paperless angelegt war. Die Rechte selbst hängen also weiterhin an Paperless-Gruppen, nur die Zuordnung der Benutzer kommt jetzt aus authentik.

Eine Kleinigkeit ist mir dabei aufgefallen: authentiks Standard-Mapping für den Scope profile schickt den vollen Namen als given_name. In Paperless stand danach „Alice Lab” als Vorname und „Lab” nochmal als Nachname. Wer das sauber getrennt haben will, kommt um ein eigenes Scope-Mapping nicht herum.

Paperless per OIDC anbinden

Die Konfiguration aus dem Lab lässt sich eins zu eins übernehmen. In authentik braucht Paperless dafür einen OAuth2-Provider und eine Application mit Policy-Binding. Als Blueprint sieht der Provider so aus:

- model: authentik_providers_oauth2.oauth2provider
  state: present
  identifiers: {name: paperless}
  id: provider
  attrs:
    name: paperless
    client_type: confidential
    client_id: paperless
    grant_types: [authorization_code, refresh_token]
    redirect_uris:
      - matching_mode: strict
        url: https://paperless.example.com/accounts/oidc/authentik/login/callback/
    signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
    authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
    invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
    property_mappings:
      - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
      - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
      - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]

Das Client-Secret fehlt hier, weil authentik es selbst erzeugt. Der Pfad der Redirect-URI stammt von django-allauth: /accounts/oidc/<provider_id>/login/callback/, wobei <provider_id> dem Wert aus der Paperless-Konfiguration entspricht. Stimmt er nicht exakt überein, lehnt authentik den Login mit redirect_uri_no_match ab.

Auf der Paperless-Seite genügen ein paar Umgebungsvariablen:

PAPERLESS_APPS: allauth.socialaccount.providers.openid_connect
PAPERLESS_SOCIALACCOUNT_PROVIDERS: >-
  {"openid_connect": {"OAUTH_PKCE_ENABLED": true, "APPS": [{
    "provider_id": "authentik", "name": "authentik",
    "client_id": "paperless", "secret": "<aus authentik>",
    "settings": {"server_url": "https://auth.example.com/application/o/paperless/.well-known/openid-configuration"}}]}}
PAPERLESS_SOCIAL_AUTO_SIGNUP: "true"
PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS: "true"

Der Slug in der server_url ist der Slug der Application in authentik, nicht der Name des Providers. Zwei Stellen davon haben im Lab Zeit gekostet: Paperless baut die Redirect-URI standardmässig mit https auf. Hinter TLS passt das, ohne TLS muss PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL auf http gesetzt werden, sonst stimmt die URI nicht. Und solange authentik das Discovery-Dokument noch nicht ausliefert, etwa direkt nach dem Anlegen des Providers, quittiert Paperless einen Login-Versuch mit einem nackten 500 statt einer verständlichen Fehlermeldung.

Wer von Forward Auth umstellt, hat in Paperless meist schon Benutzer, die über den Header entstanden sind und kein verknüpftes OIDC-Konto haben. Wie Paperless beim ersten OIDC-Login mit einem gleichnamigen Benutzer umgeht, habe ich im Lab nicht getestet. Vor der Umstellung lohnt sich deshalb ein Testlauf mit einem unkritischen Konto.

Die Login-Seite: Gate davor oder abschalten

Ein Nachteil von OIDC bleibt bestehen: Die Login-Seite der Anwendung ist öffentlich erreichbar, inklusive Passwortfeld. Für die fünf OIDC-Dienste in meinem Setup habe ich das bisher über ein zusätzliches Forward-Auth-Gate gelöst. Der Browser muss dann zuerst durch authentik, bevor er die Login-Seite überhaupt zu Gesicht bekommt, und die OIDC-Anmeldung danach läuft ohne weitere Eingabe über dieselbe authentik-Session. Für Proxmox passt das gut. Bei Paperless würde dasselbe Gate aber die Apps wieder aussperren.

Paperless bietet dafür eine Alternative, die ich als dritte Variante mitgetestet habe:

PAPERLESS_DISABLE_REGULAR_LOGIN: "true"
PAPERLESS_REDIRECT_LOGIN_TO_SSO: "true"

Das Passwortfeld verschwindet, und ein direkter POST mit Benutzername und Passwort an das Login-Formular wird abgewiesen. Die Weiterleitung zu authentik ist dabei allerdings keine serverseitige Umleitung, sondern ein kleines Skript im Login-Template, das den SSO-Button automatisch abschickt. Für Menschen im Browser macht das aber keinen spürbaren Unterschied.

Bei der API sieht die Sache anders aus. /api/token/ gibt mit Benutzername und Passwort weiterhin ein Token heraus, und auch HTTP Basic Auth gegen die API funktioniert nach wie vor. Für die Apps ist genau das nötig. Es bedeutet aber auch, dass lokale Passwörter gültig bleiben, obwohl sie in der Oberfläche gar nicht mehr auftauchen. Wer sie wirklich loswerden will, muss sie direkt am Benutzer entfernen – beim Admin-Konto, das beim ersten Start automatisch angelegt wird, vergisst man das leicht.

Was im Betrieb noch dazukam

Neben dem Lab gab es in meinem echten Setup noch ein paar Stolperstellen, die in keiner Anleitung prominent stehen:

  • grant_types bleibt leer. Ein OAuth2-Provider, der per Blueprint ohne grant_types angelegt wird, lehnt jeden Login mit invalid_request ab. authorization_code und refresh_token müssen dafür explizit gesetzt sein.
  • Proxmox und das sub-Claim. authentik liefert als sub standardmässig einen 64 Zeichen langen Hash. Proxmox verwendet dieses Claim als Benutzernamen und läuft dabei in seine eigene Längengrenze. Mit preferred_username als Username-Claim klappt es.
  • Proxmox braucht zusätzlich Rechte. OIDC übernimmt nur den Login, nicht die Autorisierung. Ohne ACL sieht ein neuer Benutzer keine einzige VM. Mit --groups-claim groups und --groups-autocreate legt Proxmox die authentik-Gruppen beim ersten Login selbst an, die Rolle auf dieser Gruppe muss dann aber einmalig von Hand vergeben werden.
  • Technitium erkennt Benutzer nur am sub. Ein vorher lokal angelegter Benutzer mit demselben Namen wird beim SSO-Login nicht übernommen, sondern neu angelegt. Ausserdem erwartet Technitium ein Claim namens roles, nicht groups.
  • Zwei Timer bei Forward Auth. Die Session des Outposts und die authentik-Session laufen unabhängig voneinander ab. Läuft die Outpost-Session zuerst ab, landen Hintergrund-Requests einer Single-Page-App in einem Cross-Origin-Redirect, dem der Browser nicht folgt – die App hängt dann einfach still, bis man die Seite neu lädt. Ich halte deshalb beide Timer auf derselben Länge.

Noch eine Beobachtung aus dem Lab: Nach einem frischen Start antwortet das Forward-Auth-Gate eine Weile lang mit 404, in meinen Läufen bis zu gut einer Minute, bis der Outpost die neuen Provider geladen hat. In dieser Zeit lässt es gar nichts durch. Als Fehlerverhalten ist das die richtige Richtung, beim ersten Mal sorgt es trotzdem für Verwirrung.

Wann welcher Weg

Für mich ergibt sich aus dem Test eine einfache Reihenfolge:

  1. Anwendung mit eigenen Clients (Apps, API, Sync): OIDC, ohne Gate davor. Kann die Anwendung kein OIDC, bleibt sie bei ihrer eigenen Anmeldung und steht gar nicht erst hinter authentik.
  2. Reine Browser-Anwendung mit OIDC-Unterstützung: OIDC, zusätzlich mit einem Gate, das die Login-Seite verbirgt.
  3. Reine Browser-Anwendung ohne eigene Anmeldung: Forward Auth – genau dafür ist es gemacht.
  4. Forward Auth mit Header-Login: nur dann, wenn der Container ausschliesslich über den Reverse Proxy erreichbar ist.

Paperless fällt damit in die erste Kategorie. Die Umstellung kostet etwas Konfigurationsarbeit, beseitigt dafür aber die Abhängigkeit vom Netzwerkweg und macht die Apps wieder benutzbar. Im Gegenzug muss ich akzeptieren, dass eine Sperre in authentik bei Paperless erst mit der nächsten Anmeldung greift.

Die Grenzen des Tests sind klar erkennbar: eine Anwendung, eine Version, kein TLS im Lab und simulierte statt echte Apps. Andere Anwendungen setzen Remote-User und OIDC jeweils anders um, und die Aussage zum Header-Login gilt für Anwendungen mit vergleichbarer Middleware, nicht automatisch für alle. Die Grundstruktur dürfte sich trotzdem übertragen lassen: authentik entscheidet bei beiden Wegen gleich, wer rein darf. Wie lange diese Entscheidung gilt und wer sie umgehen kann, hängt dagegen vom gewählten Weg ab.