Warum eigene 2FA-Integration statt Jetstream/Fortify?

Viele Teams starten nicht „greenfield“. Das Backend steht, die SPA (Vue/React) ist live, der Login läuft über JSON-Endpoints, und sensible Routen hängen an auth:sanctum. Genau in dieser Situation wirkt ein kompletter Wechsel auf Jetstream/Fortify oft wie ein Umbau am offenen Herzen.

Der praktische Weg ist deshalb: 2FA Laravel als gezielte Erweiterung in den bestehenden Auth-Flow setzen – mit klarer Trennung zwischen Passwort-Login und zweitem Faktor, mit sauberen API-Responses (keine HTML-Redirects) und mit UX-Details wie „Trusted Devices“ und Laravel Recovery Codes.

Der Artikel beschreibt eine Implementierung, die in einer realen Laravel-SPA-Architektur funktioniert:

  • Laravel API mit auth:sanctum für geschützte Endpoints. (laravel.com)
  • TOTP mit pragmarx/google2fa-laravel (als Bridge für Google2FA). (packagist.org)
  • QR-Code serverseitig per SVG, ohne externe Dienste, mit bacon/bacon-qr-code. (packagist.org)

Wenn Sie Ihre Laravel-Grundlagen zu Routen/Middleware auffrischen wollen, passt als Vorwissen unser Beitrag zu Laravel Routing sehr gut.

Zielbild: 2FA ist Teil des Login- und Request-Lebenszyklus

In einer SPA darf 2FA nicht „ein Modal irgendwann nach dem Login“ sein. Sie brauchen stattdessen einen verlässlichen Zustand:

  • Passwort-Login: Zugangsdaten stimmen.
  • 2FA-Status: Benutzer hat 2FA aktiv oder muss es einrichten.
  • Challenge-Phase: Benutzer ist bewusst noch nicht „voll eingeloggt“, bis TOTP/Recovery-Code geprüft ist.
  • Trusted Device: optionaler Komfort, aber kein Abschalten des zweiten Faktors.

Das Ergebnis sind klare Antworten der API:

  • requires_2fa_setup
  • requires_2fa_verification

… und im Frontend ein globaler Fehler-Interceptor, der die SPA in Setup/Challenge routet.

Pakete und Stack: was wirklich gebraucht wird

Im Beispiel kommen zwei Composer-Pakete zum Einsatz:

  • pragmarx/google2fa-laravel:^2.3 – Stand heute ist v2.3.0 auf Packagist gelistet. (packagist.org)
  • bacon/bacon-qr-code:^3.0 – auf Packagist ist die 3.x-Linie verfügbar (z. B. v3.0.3), mit PHP-Anforderung ^8.1. (packagist.org)

Wichtig für die Planung: Wenn Sie ein älteres PHP im Produktivsystem haben, kann bacon/bacon-qr-code 3.x dadurch ausscheiden. Dann müssen Sie entweder PHP anheben oder eine alternative QR-Code-Bibliothek wählen.

Datenmodell: 2FA-Daten direkt am User – aber verschlüsselt

Die Implementierung speichert 2FA-Zustand und Geräte-Vertrauen in der users-Tabelle. Das reduziert Join-Komplexität und macht den Flow einfacher.

Migrationsschema:

$table->text('two_factor_secret')->nullable();
$table->text('two_factor_recovery_codes')->nullable();
$table->timestamp('two_factor_confirmed_at')->nullable();
$table->json('two_factor_device_fingerprints')->nullable();

Warum confirmed_at Pflicht ist

Ein generiertes Secret ist noch keine aktivierte 2FA. Nutzer brechen Setup-Prozesse ab, wechseln Geräte, schließen Tabs.

Darum gilt: 2FA ist nur aktiv, wenn Secret vorhanden und Setup bestätigt ist.

Im User-Modell:

public function hasTwoFactorEnabled(): bool
{
    return ! is_null($this->two_factor_secret)
        && ! is_null($this->two_factor_confirmed_at);
}

Verschlüsselung: Secrets und Recovery Codes gehören nicht im Klartext in die DB

Laravel bringt dafür eine eingebaute Verschlüsselungsschicht mit (Crypt/Helper), die OpenSSL nutzt und auf dem APP_KEY basiert. (laravel.com)

In der Praxis bedeutet das:

  • two_factor_secret wird als encrypt($secret) gespeichert.
  • Recovery-Codes werden als JSON serialisiert und ebenfalls verschlüsselt gespeichert.

Das schützt vor „DB-Leak = sofort alle Tokens weg“. Es ersetzt aber nicht grundlegende Infrastruktur-Sicherheit.

Mehr zu DB-Design und Migrationen finden Sie auch in unserem Artikel zu Laravel Datenbank.

Setup-Flow: QR-Code erzeugen, bestätigen, Recovery Codes sichern

Der Setup-Flow in einer SPA funktioniert gut als Stepper:

  1. QR-Code erzeugen (plus Secret als manuelle Fallback-Anzeige)
  2. TOTP-Code bestätigen
  3. Recovery Codes anzeigen und explizit bestätigen lassen

Step 1: Secret erzeugen und QR-Code als SVG liefern

Beispiel (Enable-Endpoint):

$secret = $this->google2fa->generateSecretKey();

$user->two_factor_secret = encrypt($secret);
$user->two_factor_confirmed_at = null;
$user->two_factor_recovery_codes = null;
$user->two_factor_device_fingerprints = null;
$user->save();

Danach erzeugt das Backend ein SVG (mit BaconQrCode) und gibt es an die SPA zurück. Vorteil: Kein externes QR-Service, kein Datenabfluss.

Zu bacon/bacon-qr-code: Das Paket ist ein QR-Code-Generator für PHP und wird breit genutzt. (packagist.org)

Step 2: TOTP bestätigen (TOTP Laravel)

Der Benutzer tippt den 6-stelligen Code aus Authenticator-App.

Serverseitig:

  • Secret entschlüsseln
  • TOTP prüfen
  • bei Erfolg two_factor_confirmed_at setzen

pragmarx/google2fa-laravel ist genau für „Codes prüfen und QR-Codes erzeugen“ gedacht. (packagist.org)

Step 3: Laravel Recovery Codes generieren und speichern

Recovery Codes sind die Notfall-Option, wenn das Handy weg ist.

Im Projekt:

  • 8 Codes
  • One-Time: ein verwendeter Code wird aus der Liste entfernt
  • Sicherheitsentscheidung: Nach Verwendung eines Recovery Codes wird 2FA komplett zurückgesetzt und neu eingerichtet

Das ist nicht die einzige mögliche Strategie, aber eine konsistente: Wer gerade einen Notfallcode gebraucht hat, hatte wahrscheinlich ein Gerät verloren oder gewechselt. Eine Neuinitialisierung macht den Zustand wieder sauber.

Login-Flow: Passwort-Login in „pending“ Zustand aufteilen

Klassisches Problem bei SPAs: Sie möchten nach Passwort-Login nicht sofort ein vollwertiges Token/Session-Flag setzen, wenn 2FA aktiv ist.

Die Lösung hier: Brücke über die Session.

  • Nach erfolgreichem Passwort-Check wird geprüft, ob 2FA aktiv ist.
  • Ist das Gerät nicht vertraut, setzt das Backend 2fa_pending_user_id in die Session und loggt wieder aus.
  • Die API antwortet mit requires_2fa_verification.

Laravel unterstützt Session-Zugriff über session() Helper bzw. Request-Session-API. (laravel.com)

Beispiel-Logik:

if ($user->hasTwoFactorEnabled()) {
    $fingerprint = $this->getDeviceFingerprint($request, $clientFingerprint);
    $isDeviceTrusted = $user->isDeviceTrusted($fingerprint, $clientFingerprint);

    if (! $isDeviceTrusted) {
        session(['2fa_pending_user_id' => $user->id]);
        Auth::logout();

        return response()->json([
            'requires_2fa_verification' => true,
            'pending_user_id' => $user->id,
        ]);
    }
}

if (! $user->hasTwoFactorEnabled()) {
    return response()->json([
        'requires_2fa_setup' => true,
    ]);
}

Hinweis aus der Praxis: Die Reihenfolge ist Absicht. Erst prüfen, ob 2FA aktiv ist. Nur wenn nicht aktiv, Setup erzwingen.

Verifikation: POST /api/2fa/verify als „Sonder-Endpunkt“

Der Verify-Endpunkt bekommt:

  • TOTP-Code oder Recovery Code
  • optional: „dieses Gerät 90 Tage vertrauen“

Bei Erfolg:

  • User wird final eingeloggt
  • Session-Flag 2fa_verified wird gesetzt

Warum das sauber ist:

  • Während der Challenge ist der User nicht normal authentifiziert.
  • Die Session (2fa_pending_user_id) hält den Übergang stabil.
  • Nach Erfolg wird aus „pending“ ein regulärer Login.

Trusted Devices: Komfort ohne 2FA-Abschaltung

„Trusted Device“ heißt nicht „2FA ist aus“. Es heißt: Für dieses Gerät überspringt die App die erneute Abfrage für einen begrenzten Zeitraum.

Im Projekt:

  • Speicherung pro Gerät (Fingerprint + Metadaten)
  • Ablaufdatum (expires_at) nach 90 Tagen
  • maximal 10 Devices pro User

Beispiel-Datenstruktur:

  • fingerprint
  • user_agent
  • added_at
  • expires_at
  • optional client_fingerprint
  • optional device_info

Fingerprint-Strategie: keine IP-Adresse (mobilfreundlich)

Viele „Device Fingerprints“ brechen auf Mobilgeräten, wenn IPs häufig wechseln (Mobilfunk, VPN, Hotel-WLAN). Die Implementierung nutzt deshalb serverseitig eher stabile Faktoren:

  • normalisierter User-Agent
  • Accept-Language
  • Accept-Encoding
  • optional einen zusätzlichen Client-Fingerprint aus dem Browser

Die SPA kann einen eigenen Fingerprint aus Browser-Eigenschaften erzeugen (Zeitzone, Sprache, Hardware Concurrency etc.) und in jedem Request als Header senden, z. B. X-Device-Fingerprint.

Wichtig: Fingerprints sind nie „magisch eindeutig“. Sie sind ein pragmatischer Kompromiss für UX. Halten Sie das Zeitfenster begrenzt und bieten Sie eine Geräteverwaltung an.

Middlewares: 2FA zentral erzwingen – ohne SPA-Redirects

Der Kernpunkt für eine bestehende API: Sie wollen 2FA nicht „an ein paar Endpoints“ kleben. Sie wollen sie zentral erzwingen, so wie Sie auch auth:sanctum zentral nutzen.

Sanctum schützt Endpoints typischerweise über auth:sanctum. (laravel.com)

Darüber legen Sie zwei eigene Middlewares:

  • Require2FASetup: blockt User ohne aktivierte 2FA mit requires_2fa_setup
  • Verify2FA: blockt User mit aktivierter 2FA, aber ohne verifizierte Session und ohne Trusted Device, mit requires_2fa_verification

Der wichtigste Stolperstein: 2FA-Routen ausnehmen

Wenn Ihre Middleware auch /api/2fa/verify blockt, sperren Sie Nutzer zuverlässig aus. Nehmen Sie daher Ihre 2FA-Setup- und 2FA-Verify-Endpunkte explizit aus.

Warum JSON-Flags statt Redirects

Eine SPA braucht Zustandsflags. HTML-Redirects sind bei API-first Clients meist nur schwer sauber zu verarbeiten.

Frontend in der SPA: Interceptor + Router statt „2FA als Sonderfall“

In Vue (analog in React) hat sich folgende Struktur bewährt:

  • Axios Request-Interceptor setzt X-Device-Fingerprint
  • Axios Response-Interceptor reagiert auf 403 und prüft Flags
  • requires_2fa_setup → Route TwoFactorSetup
  • requires_2fa_verification → Route TwoFactorChallenge

Damit behandeln Sie 2FA wie einen Teil des Auth-Zustands, nicht wie ein Popup.

Wenn Sie viel mit Vue-Routing arbeiten, passt unser Beitrag zur Back-Funktion in Vue mit Vue Router als ergänzender Baustein, weil 2FA-Flows oft „Zurück“-Logik und Guarding brauchen.

Betrieb und Administration: Deaktivieren, Reset, Device-Management

In der Realität brauchen Sie mehr als „Setup“:

  • 2FA deaktivieren (mit Passwort-Bestätigung)
  • einzelne Trusted Devices entfernen
  • Recovery Codes neu generieren
  • Admin-Reset für Benutzer (UI + CLI)

Für Support-Fälle ist ein CLI-Command Gold wert, weil Sie nicht auf UI oder DB-Manual-Fixes angewiesen sind.

Dev-Bypass: praktisch, aber hart eingezäunt

Für lokale Entwicklung ist ein Bypass wie 000000 bequem, solange Sie ihn streng begrenzen:

  • nur in Nicht-Produktionsumgebungen
  • nur für localhost oder .test
  • im Code klar dokumentieren
  • idealerweise zusätzlich über Feature-Flag oder ENV aktivierbar

Sobald so ein Bypass in Staging/Prod landet, verlieren Sie den gesamten Sicherheitsgewinn.

Stolperfallen aus der Praxis (die erfahrungsgemäß wirklich passieren)

  • 2FA zu früh „aktiv“ setzen: Secret generiert ≠ Setup bestätigt. Nutzen Sie two_factor_confirmed_at.
  • Unverschlüsselte Secrets: Speichern Sie Secret und Recovery Codes ausschließlich verschlüsselt (Laravel Encrypter). (laravel.com)
  • Trusted Devices ohne Ablauf: Dann wird aus „Komfort“ schnell „dauerhaft ohne 2FA“.
  • 2FA-Endpunkte nicht ausgenommen: Ergebnis ist eine 403-Schleife.
  • Backend denkt in Redirects: SPA braucht Flags und Frontend-Routing.
  • IP als Fingerprint-Bestandteil: mobil unbrauchbar.

Wenn Sie ohnehin gerade an einer sauberen API-Struktur arbeiten, kann auch unser Beitrag zur automatischen Generierung von API-Dokumentation in Laravel helfen, weil 2FA-Flags (requires_2fa_*) dokumentiert werden sollten.

Wenn Sie zu solchen Praxis-Implementierungen mehr lesen möchten, finden Sie auf dem ADMIN CODE Blog weitere Laravel-Artikel. Und falls Sie bei der Absicherung eines produktiven Systems Unterstützung brauchen, können Sie uns über die Kontaktseite erreichen: https://www.admin-intelligence.de/kontakt/

Ähnliche Beiträge