2FAuth mit Docker Compose: Zwei-Faktor-Konten selbst hosten
2FAuth verwaltet TOTP- und HOTP-Konten selbst gehostet im Browser und löst damit das Problem geteilter Dienstkonten, deren zweiter Faktor sonst auf einem einzelnen Privathandy liegt. Diese Anleitung zeigt die vollständige Installation mit Docker Compose, erklärt jede wichtige Umgebungsvariable an der offiziellen Dokumentation, belegt Verschlüsselung, Backup und Wiederherstellung mit echten Testausgaben und benennt offen, wo die Sicherheitsgrenzen dieser Bündelung liegen.

Zwei-Faktor-Authentifizierung ist in kleinen Unternehmen meistens an Privathandys gebunden. Das funktioniert, solange jeder Zugang genau einer Person gehört. Sobald ein Dienstkonto, ein Kundenzugang oder ein gemeinsamer Provider-Account im Spiel ist, wird es unangenehm: Der zweite Faktor liegt auf dem Telefon eines einzelnen Mitarbeiters, und wenn dieser im Urlaub ist, das Gerät verliert oder das Unternehmen verlässt, steht das Team vor einer verschlossenen Tür.
2FAuth ist eine selbst gehostete Weboberfläche, die TOTP- und HOTP-Konten speichert, Codes im Browser erzeugt und seit Version 7 einzelne Konten kontrolliert an andere Benutzer freigeben kann, ohne das zugrunde liegende Geheimnis preiszugeben. Diese Anleitung zeigt eine vollständige Installation mit Docker Compose, erklärt jede relevante Umgebungsvariable, prüft Backup und Wiederherstellung praktisch nach und benennt am Ende offen, wo die Sicherheitsgrenzen dieses Ansatzes liegen. Sie richtet sich an Administratoren, die den Stack auf einem eigenen Server betreiben und dabei nicht hinter einen offenen Port ins Internet rutschen wollen.
Nutzen und Grenzen
2FAuth löst ein organisatorisches Problem, kein kryptografisches. Der Nutzen liegt darin, dass ein zweiter Faktor für geteilte Dienstkonten nicht mehr an ein einzelnes Gerät gebunden ist. Ein kleines Team kann sich an der Instanz anmelden, den Code im Browser abholen und fertig. Bei Onboarding und Offboarding entfällt die Bastelei mit abfotografierten QR-Codes, die anschließend in irgendeinem Chatverlauf hängen bleiben.
Genauso ehrlich muss die Kehrseite benannt werden. Wer alle zweiten Faktoren in einer Anwendung bündelt, baut ein attraktives Ziel. Ein Angreifer, der Zugriff auf die Instanz bekommt, erhält in einem Schritt die zweiten Faktoren vieler Konten. Diese Konzentration ist nur dann vertretbar, wenn drei Bedingungen erfüllt sind:
- Die Instanz ist nicht ungeschützt aus dem Internet erreichbar, sondern hinter VPN, Reverse Proxy mit Authentifizierung oder zumindest strikter IP-Beschränkung.
- Passwörter liegen nicht im selben System. Wer Passwortmanager und 2FA-Verwaltung zusammenlegt, hat faktisch wieder einen Faktor, nur mit mehr Schritten. Vaultwarden oder Passbolt gehören auf eine andere Instanz, idealerweise mit anderem Zugang.
- Die Benutzerkonten in 2FAuth selbst sind stark abgesichert, bevorzugt mit einem Passkey beziehungsweise WebAuthn-Schlüssel statt eines Passworts.
Für persönliche Konten einzelner Mitarbeiter bleibt die Authenticator-App auf dem Telefon die sauberere Lösung. 2FAuth ist das Werkzeug für die Fälle dazwischen: Dienstkonten, Kundenzugänge, Verwaltungsoberflächen von Providern, gemeinsam genutzte Portale. Genau dafür hat das Projekt seit Version 7 die Freigabefunktion, bei der ein Benutzer Codes erzeugen darf, ohne das Geheimnis selbst je zu sehen.
Noch eine Grenze, die häufig übersehen wird: 2FAuth ist kein Ersatz für eine Notfallplanung. Wiederherstellungscodes der jeweiligen Dienste gehören getrennt aufbewahrt, am besten offline. Wenn die Instanz ausfällt und niemand einen alternativen Weg in die Konten hat, ist das Team ausgesperrt.
Voraussetzungen und Ressourcen
Die Anforderungen sind bescheiden. 2FAuth ist eine PHP-Anwendung, die im offiziellen Container zusammen mit Nginx und PHP-FPM unter einem Supervisor läuft und ihre Daten standardmäßig in einer SQLite-Datei ablegt.
| Punkt | Anforderung |
|---|---|
| Betriebssystem | Beliebiges Linux mit Docker Engine, im Test Ubuntu-Host mit Docker 29.1.3 |
| Docker Compose | Compose V2, im Test Version 2.40.3 |
| Arbeitsspeicher | Unter 256 MB im Leerlauf, das Image belegt rund 211 MB auf der Platte |
| Speicherplatz | Wenige hundert Megabyte, die SQLite-Datei startet bei etwa 292 KB |
| Netz | Ein freier Port auf dem Host, im Beispiel an 127.0.0.1 gebunden |
| Vor der Instanz | Reverse Proxy mit TLS, siehe Abschnitt zur Netzwerkfreigabe |
Der Container läuft nach eigener Aussage der Dokumentation ohne Root-Rechte als Benutzer mit UID und GID 1000. Das ließ sich im Test bestätigen: Alle Prozesse im Container, vom Einstiegsskript über Supervisord bis zu Nginx und den PHP-FPM-Pools, liefen unter UID 1000. Für das Verzeichnis auf dem Host bedeutet das, dass es demselben Benutzer gehören muss, sonst scheitert das Anlegen der Datenbank.
Unterstützte Plattformen und getestete Versionen
Laut offizieller Docker-Dokumentation ist das Image für die Architekturen amd64, 386, arm64, arm/v6 und arm/v7 gebaut. Das deckt neben gewöhnlichen Servern auch Raspberry-Pi-Hardware ab. Getestet wurde für diese Anleitung ausschließlich amd64 unter Linux, die ARM-Varianten sind hier nur dokumentiert, nicht selbst geprüft.
| Komponente | Im Test beobachtete Version |
|---|---|
| 2FAuth | 8.0.2, Commit 2023d68, Image gebaut am 2. September 2026 |
| PHP | 8.4.25 (fpm-fcgi) |
| Nginx im Container | 1.30.4 |
| Supervisord | v0.6.8 |
| Image-Architektur | linux/amd64, Label org.opencontainers.image.version = 8.0.2 |
Zum Stand der Abfrage über die GitHub-API am 22. September 2026 hat das Repository Bubka/2FAuth 4156 Sterne, ist nicht archiviert, steht unter der AGPL-3.0 und hat als jüngsten Push den 2. September 2026. Das aktuellste Release ist v8.0.2, veröffentlicht am 2. September 2026. Angenehm: Der Tag latest lieferte im Test tatsächlich exakt diese Version und lag ausnahmsweise nicht hinter dem Release zurück, was bei vielen Projekten sonst der Fall ist.
Zur Tag-Auswahl nennt die Dokumentation vier Varianten: latest für den Stand des master-Branches, unveränderliche Versions-Tags wie 8.0.2, bewegliche Teilversions-Tags wie 8 oder 8.0 sowie dev für den Entwicklungszweig. Für den produktiven Betrieb ist ein Versions-Tag die richtige Wahl, weil ein unbeabsichtigtes Update dann nicht schon beim nächsten Neustart passiert.
Verzeichnis anlegen und APP_KEY erzeugen
Zuerst das Arbeitsverzeichnis mit dem Datenordner. Die Besitzrechte müssen zu UID und GID 1000 im Container passen.
# Arbeitsverzeichnis und Datenordner anlegen
mkdir -p /opt/2fauth/data
cd /opt/2fauth
# Besitzer und Rechte an die Container-UID anpassen
chown -R 1000:1000 data
chmod 700 data
Der wichtigste Schritt vor dem ersten Start ist der Verschlüsselungsschlüssel. 2FAuth ist eine Laravel-Anwendung, und APP_KEY ist der Schlüssel, mit dem laut Dokumentation sämtliche sicherheitsrelevanten Funktionen arbeiten: Sitzungen, Datenbankverschlüsselung, WebAuthn und persönliche Zugriffstoken. Die Dokumentation formuliert unmissverständlich, dass alle verschlüsselten Daten in der Datenbank als verloren gelten müssen, wenn dieser Schlüssel abhandenkommt. Es gibt dafür keinen Umweg.
Erzeugen lässt er sich direkt mit dem 2FAuth-Image, ohne den Stack zu starten:
# Neuen Schluessel lokal mit dem 2FAuth-Image erzeugen
docker run -it --rm --entrypoint /usr/bin/php 2fauth/2fauth artisan key:generate --show
Die Ausgabe ist eine Zeile der Form base64:... mit einem 32 Byte langen Zufallswert. Genau dieser Wert gehört in die Konfiguration und zusätzlich an einen sicheren, vom Server getrennten Ort. Ein Schlüssel im gleichen Backup wie die Datenbank schützt nur vor versehentlichem Löschen, nicht vor einem Diebstahl des Backups.
Warum der Schlüssel nach dem ersten Start nicht mehr einfach ausgetauscht werden darf, lässt sich sehr konkret zeigen. Im Test wurde ein TOTP-Konto angelegt, die Verschlüsselung aktiviert, dann der APP_KEY durch einen frisch erzeugten ersetzt und der Container neu gestartet. Die Anmeldung funktionierte noch, der Codeabruf nicht mehr:
# Reale Antwort der API nach unbedachtem Schluesselwechsel
{"message":"The secret cannot be deciphered. This is mainly caused by a wrong APP_KEY
set in the .env configuration file of 2Fauth or a corrupted data stored in database."}
# HTTP 400
Die Daten waren damit nicht zerstört, aber unlesbar. Der von der Dokumentation vorgesehene Ausweg ist die Variable APP_PREVIOUS_KEYS, eine kommagetrennte Liste früherer Schlüssel. Auch das wurde geprüft: Nach Eintragen des alten Schlüssels in APP_PREVIOUS_KEYS und einem erneuten Neustart lieferte derselbe Abruf wieder einen gültigen Code mit HTTP 200. Eine Schlüsselrotation ist also möglich, aber nur bewusst und mit dem alten Schlüssel in der Hand. Wer ihn verloren hat, hat die Geheimnisse verloren.
compose.yaml und Umgebungsvariablen
Das offizielle Compose-Beispiel des Projekts ist bewusst knapp gehalten und besteht aus einem einzigen Dienst, der seine Variablen aus einer Datei settings.env bezieht. Die folgende Fassung übernimmt diese Struktur, bindet den Port aber ausdrücklich nur an die Loopback-Adresse und ergänzt einen Healthcheck sowie eine Neustartrichtlinie.
services:
2fauth:
image: 2fauth/2fauth:8.0.2
container_name: 2fauth
restart: unless-stopped
env_file: settings.env
volumes:
- ./data:/2fauth
ports:
# Nur lokal erreichbar, der Reverse Proxy spricht die Instanz an
- "127.0.0.1:8000:8000/tcp"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8000/"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
Der Healthcheck nutzt das im Image vorhandene wget. Das ist kein Zufallsgriff, sondern im Test geprüft: which wget im Container lieferte /usr/bin/wget, ein interner Abruf gegen Port 8000 war erfolgreich, und nach dem Start meldete docker inspect den Status healthy mit zwei Prüfungen vom Exit-Code 0. Die start_period von 60 Sekunden ist großzügig gewählt, weil der erste Start Migrationen ausführt und die Anwendung in dieser Zeit noch mit HTTP 502 antwortet.
Die zugehörige settings.env enthält keine echten Geheimnisse, der Schlüssel unten ist ein Platzhalter und muss ersetzt werden:
# Allgemein
APP_NAME=2FAuth
APP_ENV=production
APP_DEBUG=false
APP_TIMEZONE=Europe/Berlin
SITE_OWNER=admin@example.com
# Oeffentliche Adresse der Instanz, muss exakt zur aufgerufenen URL passen
APP_URL=https://2fa.example.com
# Platzhalter, eigenen Wert mit artisan key:generate --show erzeugen
APP_KEY=base64:BITTE_DURCH_EIGENEN_SCHLUESSEL_ERSETZEN=
# Datenbank, hier SQLite im gebundenen Volume
DB_CONNECTION=sqlite
DB_DATABASE=/2fauth/database.sqlite
# Cache und Sessions im Dateisystem
CACHE_DRIVER=file
SESSION_DRIVER=file
# Protokollierung
LOG_CHANNEL=daily
LOG_LEVEL=notice
# Mail, fuer Passwort-Zuruecksetzen noetig. log schreibt nur ins Protokoll
MAIL_MAILER=log
# Anmeldeversuche pro Minute und IP
LOGIN_THROTTLE=5
# Aufbewahrung der Protokolle in Tagen
AUTHENTICATION_LOG_RETENTION=365
OTP_LOG_RETENTION=365
# Reverse Proxy als vertrauenswuerdig markieren, IP-Liste oder *
TRUSTED_PROXIES=172.18.0.0/16
# Schutzmassnahmen
CONTENT_SECURITY_POLICY=true
BLOCK_OPTAUTH_IMAGELINK_FETCHING=true
Die wichtigsten Variablen im Einzelnen, jeweils an der offiziellen Dokumentation zu den Umgebungsvariablen abgeglichen:
| Variable | Bedeutung | Hinweis |
|---|---|---|
APP_KEY | Verschlüsselungsschlüssel für Sitzungen, Datenbankverschlüsselung, WebAuthn und Zugriffstoken | Pflicht. Genau 32 Zeichen beziehungsweise ein base64-Wert. Ohne ihn startet die Anwendung nicht. |
APP_PREVIOUS_KEYS | Kommagetrennte Liste früherer Schlüssel | Nur bei bewusster Rotation nötig, rettet bestehende Daten |
APP_KEY_FILE | Variante für Docker-Secrets, liest den Schlüssel aus einer eingehängten Datei | Hat laut Dokumentation Vorrang vor APP_KEY. Beide gleichzeitig zu setzen führt zu einem Fehler beim Start. |
APP_URL | Öffentliche Adresse der Instanz | Muss mit der tatsächlich aufgerufenen URL samt Schema und gegebenenfalls Port übereinstimmen, sonst funktionieren WebAuthn, Linkerzeugung und SSO-Weiterleitung nicht |
DB_CONNECTION | Datenbanktreiber | Zulässig sind sqlite, mysql, pgsql und sqlsrv. Der Standardwert ist mysql, das offizielle Docker-Setup arbeitet aber mit SQLite. |
DB_DATABASE | Datenbankname oder Pfad zur SQLite-Datei | Im Container sinnvollerweise unterhalb von /2fauth, damit die Datei im gebundenen Volume liegt |
DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD | Zugangsdaten für einen externen Datenbankserver | Ohne Wirkung bei SQLite. Für MySQL ist 3306 voreingestellt, für PostgreSQL 5432. |
TRUSTED_PROXIES | Kommagetrennte IP-Liste vertrauenswürdiger Proxys, alternativ * | Nötig, wenn ein Reverse Proxy TLS terminiert, sonst erzeugt die Anwendung Links mit falschem Schema. Ab Version 8 zwingend, wenn AUTHENTICATION_GUARD auf reverse-proxy-guard steht. |
AUTHENTICATION_GUARD | Anmeldeverfahren, web-guard oder reverse-proxy-guard | Bei reverse-proxy-guard prüft 2FAuth ausschließlich die Kopfzeilen des Proxys und überspringt alle eigenen Prüfungen. Der Proxy trägt dann die gesamte Verantwortung. |
LOGIN_THROTTLE | Fehlversuche pro Minute vor der Sperre | Gilt laut Dokumentation sowohl für Passwort- als auch für WebAuthn-Anmeldungen |
CONTENT_SECURITY_POLICY | Aktiviert die Content-Security-Policy | Standardmäßig aktiv, sollte aktiv bleiben |
BLOCK_OPTAUTH_IMAGELINK_FETCHING | Verhindert das Nachladen von Bildern, die in QR-Codes verlinkt sind | Schutz gegen Blind-SSRF, standardmäßig aktiv |
PHP_MEMORY_LIMIT_TEMP_OVERRIDE | Temporäres Speicherlimit während der QR-Code-Erkennung | Neu in Version 8, Standardwert 512 |
Wer den Schlüssel nicht in einer Datei mit Umgebungsvariablen stehen haben möchte, kann ihn laut Dokumentation seit Version 6 als Docker-Secret einhängen. Das Image unterstützt die Konvention mit dem Suffix _FILE für APP_KEY_FILE, DB_DATABASE_FILE, DB_USERNAME_FILE, DB_PASSWORD_FILE, DB_HOST_FILE, MAIL_USERNAME_FILE, MAIL_PASSWORD_FILE und REDIS_PASSWORD_FILE. Diese Variante wurde hier nicht selbst getestet.
Start und Erstkonfiguration
Der Start ist unspektakulär:
# Stack im Hintergrund starten
docker compose up -d
# Status und Healthcheck pruefen
docker compose ps
# Startvorgang verfolgen
docker compose logs -f
Beim ersten Start legt das Einstiegsskript die SQLite-Datei an, verknüpft das Speicherverzeichnis und führt sämtliche Migrationen aus. Die Protokollausgabe zeigt das sehr deutlich:
Running version 8.0.2 commit 2023d68 built on 2026-09-02T20:35:12Z
supervisord version: v0.6.8
PHP 8.4.25 (fpm-fcgi)
nginx version: nginx/1.30.4
DB_DATABASE sets with custom path: /2fauth/database.sqlite
/2fauth/database.sqlite does not exist, we create it
/srv/storage is now a symlink to /2fauth/storage
INFO Preparing database.
INFO Running migrations.
2014_10_12_000000_create_users_table .......................... 45.93ms DONE
2019_05_16_162730_create_twofaccounts_table ................... 15.32ms DONE
2026_03_15_000000_create_twofaccount_shares_table ............. 48.08ms DONE
2026_08_29_175933_update_oauth_clients_table_for_passport_13 . 496.79ms DONE
Wichtig für die Erwartungshaltung: Die Oberfläche antwortet nicht sofort. Im Test lieferte der Abruf in den ersten acht Sekunden gar keine Verbindung, danach kurz HTTP 502 mit der Meldung connect() failed (111: Connection refused) while connecting to upstream im Nginx-Protokoll, und erst nach rund zehn Sekunden HTTP 200. Das ist kein Fehler, sondern PHP-FPM, das noch nicht bereit ist. Deshalb die großzügige start_period im Healthcheck.
Anschließend liefert die Instanz eine vollständige Antwort mit gesetzter Content-Security-Policy und zwei Cookies. Das ließ sich im Test direkt nachvollziehen:
# Verfuegbarkeit pruefen
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/
# 200
# Kopfzeilen ansehen
curl -sI http://127.0.0.1:8000/ | head -8
# HTTP/1.1 200 OK
# Server: nginx/1.30.4
# X-Powered-By: PHP/8.4.25
# Content-Security-Policy: script-src 'nonce-...' 'wasm-unsafe-eval' 'strict-dynamic'; ...
# Set-Cookie: 2fauth_session=...; httponly; samesite=lax
Die Erstkonfiguration passiert im Browser. Beim ersten Aufruf legt man ein Konto an, und dieses erste Konto wird laut Dokumentation automatisch zum Administrator. Das ist ein Punkt, an dem man nicht trödeln sollte: Eine frisch gestartete, aus dem Netz erreichbare Instanz verteilt die Administratorrolle an denjenigen, der zuerst das Registrierungsformular ausfüllt. Deshalb gehört der erste Start hinter eine geschlossene Tür.
Die Registrierung funktioniert auch über die Weboberflächen-API, was sich für einen automatisierten Funktionstest nutzen lässt. Im Test lieferte ein POST auf /user mit gültigem CSRF-Token die Antwort {"message":"account created","id":1,...}.
Nach der Anmeldung sind im Administrationsbereich unter Admin > App Setup drei Einstellungen wichtig:
- Sensible Daten schützen aktiviert die Datenbankverschlüsselung für 2FA-Geheimnisse und otpauth-URIs. Diese Option sollte in jedem Fall aktiv sein.
- 2FA-Freigabe steuert, ob Benutzer Konten überhaupt teilen dürfen. Wird die Funktion später deaktiviert, bleiben bestehende Freigaben laut Dokumentation erhalten, werden aber inaktiv und gelten beim erneuten Aktivieren wieder.
- ALL_USERS-Bereich erlaubt Freigaben an sämtliche Benutzer der Instanz, auch an künftige. Die Dokumentation stuft das selbst als potenziell gefährlich ein. Für die meisten kleinen Teams ist die Beschränkung auf
SPECIFIC_USERSdie richtige Wahl.
Funktionstest und Verschlüsselung prüfen
Ob die Instanz wirklich arbeitet, zeigt sich am besten an einem echten TOTP-Code. Für den Test wurde ein Konto mit dem bekannten Beispielgeheimnis JBSWY3DPEHPK3PXP angelegt und der erzeugte Code gegen eine unabhängige lokale Berechnung geprüft. Beide lieferten denselben Wert, in diesem Fall 457859. Die API-Antwort enthält neben dem aktuellen Code auch den Zeitstempel und bereits das nächste Passwort:
# Reale Antwort der OTP-Abfrage im Test
{"password":"457859","otp_type":"totp","generated_at":1790036107,"period":30,"next_password":"499341"}
Deutlich aufschlussreicher ist der Blick in die Datenbank, weil er zeigt, was die Verschlüsselungsoption wirklich tut. Vor ihrer Aktivierung stand das Geheimnis im Klartext in der SQLite-Datei:
# Vor dem Aktivieren der Verschluesselung
1|S-EDV Testdienst|JBSWY3DPEHPK3PXP
# Nach dem Aktivieren, Dienstname und Geheimnis verschluesselt
1|eyJpdi...Q0E9|eyJpdi...MTRz
Das ist ein Ergebnis, das man kennen sollte: Ohne die aktivierte Option liegen die zweiten Faktoren unverschlüsselt in einer Datei, die in jedem Backup und in jedem versehentlich kopierten Verzeichnis landet. Und es erklärt zugleich, warum der APP_KEY danach unantastbar ist, denn genau dieser Schlüssel entschlüsselt die Werte wieder.
Persistente Daten und Rechte
Alles, was überleben muss, liegt im gebundenen Verzeichnis ./data, das im Container unter /2fauth eingehängt ist. Nach dem ersten Start sah es im Test so aus:
data/
├── database.sqlite # die komplette Datenbank, rund 292 KB nach der Installation
├── installed # Markierungsdatei des Einstiegsskripts
└── storage/
├── oauth-private.key # Passport-Schluessel, Rechte 0600
├── oauth-public.key # Passport-Schluessel, Rechte 0660
├── framework/ # Sessions, Cache, kompilierte Views
├── logs/ # Anwendungsprotokolle
└── app/public/icons/ # hochgeladene Dienst-Symbole
Die beiden OAuth-Schlüsseldateien verdienen Aufmerksamkeit. Die Release-Notes zu Version 8 weisen ausdrücklich darauf hin, dass die Authentifizierungskomponente nun die Dateirechte prüft. Wenn beim Aktualisieren der Fehler chmod(): Operation not permitted erscheint, gehören die Dateien meist nicht dem Benutzer 1000. Laut Release-Notes muss oauth-private.key die Rechte 0660 und oauth-public.key die Rechte 0600 haben. Im Test legte das Image die Dateien mit 0600 für den privaten und 0660 für den öffentlichen Schlüssel an, also genau umgekehrt zur Angabe in den Release-Notes, und funktionierte damit einwandfrei. Wer auf den Fehler stößt, sollte daher zuerst den mitgelieferten Reparaturbefehl verwenden, statt die Rechte von Hand zu setzen:
# Schluesselrechte durch die Anwendung selbst korrigieren lassen
docker compose exec 2fauth /usr/bin/php /srv/artisan 2fauth:fix-passport-key-permissions
# Besitzer auf dem Host pruefen und notfalls korrigieren
ls -l data/storage/oauth-*.key
chown 1000:1000 data/storage/oauth-private.key data/storage/oauth-public.key
Das Anwendungsprotokoll liegt laut Dokumentation in /2fauth/storage/logs und damit ebenfalls im gebundenen Verzeichnis. Nginx- und PHP-FPM-Protokolle schreibt der Container auf die Standardausgabe, sie erscheinen also in docker compose logs.
Sichere Netzwerkfreigabe, Reverse Proxy und TLS
Die Portangabe im Compose-Beispiel oben bindet bewusst an 127.0.0.1. Damit ist die Anwendung vom Netz aus nicht direkt erreichbar, und der Reverse Proxy ist der einzige Weg hinein. Das ist bei einer Anwendung, die zweite Faktoren bündelt, keine Feinheit, sondern die Grundeinstellung.
Eine passende Nginx-Konfiguration vor dem Container sieht so aus:
server {
listen 443 ssl http2;
server_name 2fa.example.com;
ssl_certificate /etc/letsencrypt/live/2fa.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/2fa.example.com/privkey.pem;
# Nur moderne Protokolle zulassen
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Zwei Dinge müssen dazu passen. Erstens muss APP_URL exakt die öffentliche Adresse enthalten, inklusive https und gegebenenfalls einem abweichenden Port. Die Dokumentation weist darauf hin, dass ein Aufruf über eine alternative Adresse wie eine nackte IP zwar funktioniert, aber WebAuthn, SSO-Weiterleitungen und die Linkerzeugung brechen lässt. Zweitens muss TRUSTED_PROXIES die Adresse des Proxys enthalten, sonst erzeugt die Anwendung Links mit dem falschen Schema, weil sie nur den unverschlüsselten Verkehr vom Proxy sieht. Die Fehlerseite der Dokumentation nennt genau dieses Symptom: Firefox warnt vor einer unsicheren Verbindung, obwohl TLS terminiert wird.
Für den Zugriff selbst gibt es drei vertretbare Modelle, in absteigender Sicherheit:
- Nur über VPN. Die Instanz ist aus dem Internet überhaupt nicht erreichbar. Für ein kleines Team mit fester Arbeitsumgebung der sauberste Weg.
- Vorgeschaltete Authentifizierung. Ein Forward-Auth-Dienst vor der Anwendung. Für diesen Fall bietet 2FAuth den
reverse-proxy-guard, bei dem die Anwendung ausschließlich den Kopfzeilen des Proxys vertraut. Seit Version 8 muss der Proxy dafür zwingend inTRUSTED_PROXIESstehen. Wer diesen Weg geht, sollte sich der Konsequenz bewusst sein: 2FAuth prüft dann nichts mehr selbst. - Öffentlich erreichbar mit Passkey-Pflicht. Nur vertretbar, wenn jedes Konto mit WebAuthn abgesichert ist, die Registrierung geschlossen bleibt und Anmeldeversuche begrenzt sind. Ein reines Passwort als einziger Schutz vor allen zweiten Faktoren des Unternehmens ist es nicht.
Unabhängig vom Modell gilt: Passwortmanager und 2FAuth gehören nicht auf dieselbe Instanz, nicht hinter dieselbe Anmeldung und idealerweise nicht auf denselben Host. Sonst genügt eine kompromittierte Sitzung, um beide Faktoren gleichzeitig zu erhalten.
Backup und Wiederherstellung
Beim Standard-Setup mit SQLite besteht ein vollständiges Backup aus drei Teilen: dem Datenverzeichnis, der Datei mit den Umgebungsvariablen und dem APP_KEY. Letzterer steckt zwar in settings.env, gehört aber zusätzlich getrennt gesichert, denn ohne ihn ist das Backup der verschlüsselten Einträge wertlos.
Der Container sollte für das Backup kurz gestoppt werden, damit die SQLite-Datei nicht mitten in einer Schreiboperation kopiert wird:
# Dienst anhalten, damit die SQLite-Datei konsistent ist
docker compose stop 2fauth
# Datenverzeichnis, Variablen und Compose-Datei sichern
mkdir -p backup
tar czf backup/2fauth-$(date +%F).tar.gz data settings.env compose.yaml
# Dienst wieder starten
docker compose start 2fauth
# Inhalt des Archivs kontrollieren
tar tzf backup/2fauth-$(date +%F).tar.gz | head
Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung. Deshalb wurde die Wiederherstellung im Test vollständig durchgespielt: Container beenden, Datenverzeichnis löschen, Archiv entpacken, Stack neu starten. Der Abruf der Oberfläche lieferte beim ersten Versuch wieder HTTP 200, die Anmeldung mit dem gesicherten Konto antwortete mit {"message":"authenticated"}, und der Codeabruf für den verschlüsselten Eintrag lieferte einen gültigen TOTP-Code mit HTTP 200, der wiederum mit der unabhängigen lokalen Berechnung übereinstimmte.
# Wiederherstellung durchspielen
docker compose down
rm -rf data
tar xzf backup/2fauth-2026-09-22.tar.gz
docker compose up -d
# Danach die Oberflaeche pruefen
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/
# 200
Wer eine externe MySQL- oder PostgreSQL-Datenbank verwendet, sichert statt der SQLite-Datei einen Datenbankabzug, braucht aber weiterhin das Verzeichnis storage mit den OAuth-Schlüsseln und den hochgeladenen Symbolen. Diese Variante wurde hier nicht getestet.
Ein zweiter Rettungsanker: 2FAuth kann Konten exportieren. Ein solcher Export enthält die Geheimnisse im Klartext und ist damit selbst ein hochsensibles Dokument. Er eignet sich als verschlüsselt abgelegter Notfallstand, nicht als tägliches Backup.
Updates und Rollback
Die Dokumentation beschreibt das Update für Docker knapp: neues Image ziehen, Container neu starten. Die Migrationen laufen beim Start automatisch mit.
# Vorher sichern, insbesondere die SQLite-Datei
docker compose stop 2fauth
tar czf backup/vor-update-$(date +%F).tar.gz data settings.env
# Neues Image holen und Container ersetzen
docker compose pull
docker compose up -d
# Laufende Version aus dem Protokoll ablesen
docker compose logs --no-log-prefix | grep "Running version"
Der letzte Befehl ist mehr als Kosmetik. Im Test gab der Container beim Start die Zeile Running version 8.0.2 commit 2023d68 built on 2026-09-02T20:35:12Z aus, was sich direkt gegen das Release-Tag aus der GitHub-API prüfen lässt.
Beim Rollback gilt eine harte Grenze: Migrationen verändern das Datenbankschema, und ein Zurückwechseln auf ein älteres Image nimmt diese Änderungen nicht zurück. Ein Rollback funktioniert deshalb verlässlich nur zusammen mit dem Rückspielen des Datenbank-Backups von vor dem Update. Genau dafür ist der erste Schritt oben da.
Beim Sprung auf Version 8 sind laut Release-Notes zwei Punkte zu beachten, die hier nicht selbst getestet wurden, weil die Installation direkt mit 8.0.2 erfolgte. Erstens werden alle persönlichen Zugriffstoken aus Version 7 und früher ungültig und müssen neu erstellt werden. Zweitens muss ein vorgeschalteter Authentifizierungsproxy nun zwingend in TRUSTED_PROXIES eingetragen sein, sonst funktioniert die Anmeldung über den Proxy nicht mehr.
Typische Fehler, Diagnose und Lösung
Die folgenden Fehlerbilder stammen bis auf die gekennzeichnete Ausnahme aus dem eigenen Test und wurden absichtlich provoziert.
| Symptom | Ursache | Lösung |
|---|---|---|
!! Environment variable APP_KEY is empty !! beim Start | Kein Schlüssel gesetzt | Schlüssel mit artisan key:generate --show erzeugen und in settings.env eintragen |
Bind for 127.0.0.1:8000 failed: port is already allocated | Der Host-Port ist belegt | Freien Port mit ss -tlnp suchen und die Portzuordnung in der compose.yaml ändern |
The container name "/2fauth" is already in use | Ein Container mit diesem Namen existiert noch | docker rm -f 2fauth oder container_name anpassen |
HTTP 502, im Protokoll connect() failed (111: Connection refused) while connecting to upstream | PHP-FPM ist beim frühen Abruf noch nicht bereit | Rund zehn Sekunden warten, start_period im Healthcheck ausreichend groß setzen |
The secret cannot be deciphered, HTTP 400 beim Codeabruf | Der APP_KEY wurde nach der Verschlüsselung geändert | Alten Schlüssel in APP_PREVIOUS_KEYS eintragen und den Container neu starten. Ohne den alten Schlüssel sind die Daten verloren. |
| Datenbank wird nicht angelegt, Rechtefehler im Protokoll | Das Hostverzeichnis gehört nicht UID 1000 | chown -R 1000:1000 data und chmod 700 data |
chmod(): Operation not permitted nach dem Update auf Version 8 | Falsche Besitzrechte an den Passport-Schlüsseln, laut Release-Notes | 2fauth:fix-passport-key-permissions ausführen, Besitzer auf 1000:1000 setzen. Nicht selbst getestet. |
| Browser warnt hinter dem Proxy vor unsicherer Verbindung | TRUSTED_PROXIES ist nicht gesetzt | Proxy-IP eintragen, laut Dokumentation bei mehreren Proxys kommagetrennt. Nicht selbst getestet. |
Zur Diagnose helfen drei Befehle, die im Test durchgehend brauchbare Ausgaben lieferten:
# Protokoll der Anwendung und des Webservers
docker compose logs --tail=80
# Datenbankverbindung durch die Anwendung selbst pruefen lassen
docker compose exec 2fauth /usr/bin/php /srv/artisan 2fauth:check-db-connection
# Healthcheck-Status und die letzten Pruefergebnisse
docker inspect 2fauth --format '{{.State.Health.Status}}'
Wenn sich Einstellungen nicht auswirken, liegt das meist am Konfigurationscache. Die Dokumentation nennt dafür php artisan config:clear beziehungsweise das Löschen von bootstrap/cache/config.php und anschließend php artisan config:cache. Im Container ist ein Neustart über docker compose up -d --force-recreate der einfachere Weg, weil das Einstiegsskript den Cache ohnehin neu aufbaut.
Saubere Deinstallation
Zum Entfernen genügen wenige Befehle. Die Warnung vorweg: Mit dem Datenverzeichnis verschwinden sämtliche 2FA-Geheimnisse unwiderruflich. Wenn auch nur ein Konto ausschließlich hier hinterlegt ist, sperrt dieser Schritt das Team aus dem entsprechenden Dienst aus. Vor dem Löschen also zuerst prüfen, ob für jeden Eintrag ein alternativer Zugang oder ein Wiederherstellungscode vorliegt.
# Container und Netzwerk entfernen
docker compose down --remove-orphans
# Letzte Sicherung anlegen, bevor die Daten verschwinden
tar czf ~/2fauth-final-$(date +%F).tar.gz data settings.env
# Daten endgueltig loeschen
rm -rf data
# Image entfernen
docker rmi 2fauth/2fauth:8.0.2
Einordnung für kleine Teams
2FAuth ist ein ausgereiftes Werkzeug für einen klar umrissenen Zweck. Die Installation ist in wenigen Minuten erledigt, der Container läuft ohne Root-Rechte, die Datenhaltung ist übersichtlich, und die Freigabefunktion löst genau das Problem, an dem Authenticator-Apps im Teamkontext scheitern. Wer geteilte Dienstkonten verwaltet, bekommt hier eine nachvollziehbare und auditierbare Lösung mit Protokollen über Anmeldungen und Codeerzeugungen.
Die entscheidende Frage ist aber nicht die Installation, sondern die Absicherung drumherum. Eine 2FAuth-Instanz ohne VPN oder vorgeschaltete Authentifizierung, mit passwortgeschützten Konten und ohne aktivierte Datenbankverschlüsselung, senkt das Sicherheitsniveau gegenüber verteilten Authenticator-Apps spürbar. Mit Passkey-Anmeldung, geschlossener Registrierung, aktivierter Verschlüsselung, getrenntem Passwortmanager und getesteter Wiederherstellung ist sie dagegen ein sinnvoller Baustein. Der Unterschied zwischen beiden Zuständen sind vielleicht dreißig Minuten Arbeit, und die lohnen sich an dieser Stelle mehr als fast überall sonst.
Passende Anleitungen auf S-EDV
- Zwei-Faktor-Authentifizierung im Unternehmen einführen ordnet ein, für welche Konten ein zweiter Faktor überhaupt verpflichtend sein sollte, bevor es an die Werkzeuge geht.
- Vaultwarden härten, sichern und mit Fail2ban absichern zeigt die getrennte Passwortverwaltung, die neben einer 2FAuth-Instanz stehen sollte statt darin.
- Docker Compose absichern mit Secrets, Healthchecks und Non-Root vertieft die Secrets-Variante, mit der sich der APP_KEY aus der Umgebungsdatei heraushalten lässt.
- Nginx als Reverse Proxy mit TLS einrichten liefert die vollständige Proxy-Konfiguration, auf die der Abschnitt zur Netzwerkfreigabe aufbaut.
Quellen
- 2FAuth Docs, Docker-Compose-Installation, abgerufen am 22. September 2026
- 2FAuth Docs, Environment variables, abgerufen am 22. September 2026
- 2FAuth Docs, Data protection und APP_KEY-Warnung, abgerufen am 22. September 2026
- 2FAuth Docs, 2FA sharing und Freigabebereiche, abgerufen am 22. September 2026
- GitHub, Release v8.0.2 vom 2. September 2026, Kennzahlen über die GitHub-API abgerufen am 22. September 2026
- GitHub, Repository Bubka/2FAuth, 4156 Sterne, AGPL-3.0, abgerufen am 22. September 2026