Login-Sicherheit: 2FA und Passkeys für Geschäftsanwendungen
Die meisten Einbrüche in Geschäftsanwendungen beginnen nicht mit einer raffinierten Sicherheitslücke, sondern mit einem Passwort. Es wurde bei einem anderen Dienst gestohlen und hier wiederverwendet. Es stand auf einem Zettel. Es war „Sommer2024!". Der Login ist die Tür, und in den meisten Webapps, die ich zur Prüfung bekomme, ist es eine Tür mit einem einzigen, alten Schloss.
Dieser Beitrag beschreibt, wie ich die Anmeldung in einer PHP-Anwendung heute baue: Passwörter, die richtig gespeichert sind, ein zweiter Faktor, der nicht nervt, Passkeys als Weg, das Passwort ganz loszuwerden, und die Kleinigkeiten drumherum, an denen es in der Praxis scheitert. Und weil es sich um Geschäftsanwendungen handelt, geht es auch um die Nutzerin, die sich einmal im Quartal anmeldet und ihr Handy gewechselt hat.
TL;DR - Die Kurzfassung
- Passwörter werden mit
password_hash()und Argon2id gespeichert. Nichts anderes, keine eigene Konstruktion. - Ein zweiter Faktor per Authenticator-App (TOTP) ist in einem Tag eingebaut und hält gestohlene Passwörter draußen.
- Passkeys ersetzen das Passwort durch ein Schlüsselpaar. Sie sind phishing-resistent und für Nutzer einfacher als alles davor. Synchronisierte Passkeys hängen an der Sicherheit des Apple- oder Google-Kontos, für Adminkonten gibt es hardwaregebundene.
- Ohne Recovery-Codes und einen Wiederherstellungsprozess ist jeder zweite Faktor eine Support-Hotline. Und die Wiederherstellung darf nicht schwächer sein als die Anmeldung.
- Rate-Limits, Session-Hygiene und Benachrichtigung bei neuen Anmeldungen sind der Teil, der unspektakulär ist und am meisten bringt.
Schicht 0: Das Passwort richtig speichern
Bevor es um zweite Faktoren geht, muss der erste stimmen. Ich sehe noch regelmäßig Anwendungen, die Passwörter mit MD5 oder SHA-1 speichern, manchmal ohne Salt. Diese Verfahren sind so schnell, dass ein Offline-Angriff billig wird: Wer die Datenbank hat, probiert mit einer Grafikkarte Milliarden Kandidaten pro Sekunde durch, und alle Passwörter, die in Wörterbüchern oder alten Datenlecks vorkommen, fallen in Minuten. Damit sind die Konten der Nutzer bedroht, und mit ihnen die Konten der Nutzer bei allen anderen Diensten, wo sie dasselbe Passwort verwenden.
PHP bringt seit Jahren die richtige Lösung mit, und sie besteht aus zwei Zeilen:
// Kostenparameter: auf der eigenen Hardware kalibrieren, Ziel etwa 0,3 bis 0,5 s pro Hash.
// Ohne Angabe prüft password_needs_rehash nur gegen die PHP-Defaults.
$opts = ['memory_cost' => 65536, 'time_cost' => 4, 'threads' => 2];
// Beim Anlegen oder Ändern des Passworts
$hash = password_hash($passwort, PASSWORD_ARGON2ID, $opts);
// Beim Login
if (password_verify($eingabe, $hash)) {
if (password_needs_rehash($hash, PASSWORD_ARGON2ID, $opts)) {
// alte Hashes (oder alte Parameter) bei Gelegenheit auf den Stand bringen
$neu = password_hash($eingabe, PASSWORD_ARGON2ID, $opts);
// ... speichern
}
// angemeldet
}
Argon2id ist absichtlich langsam und speicherhungrig, damit das Durchprobieren von Milliarden Kandidaten unbezahlbar wird. Wie langsam, bestimmen die Kostenparameter, und die gehören auf der eigenen Hardware kalibriert und mit der Zeit erhöht. Der Salt wird automatisch erzeugt und im Hash abgelegt. Und password_needs_rehash erlaubt es, eine bestehende Anwendung mit alten Hashes oder alten Parametern ohne Zwangsreset umzustellen: Bei jedem erfolgreichen Login wird der Hash erneuert.
Zur Passwortregel selbst: Lange Passwörter sind wichtiger als komplizierte. Ein Mindestmaß von zwölf Zeichen, keine Pflicht zu Sonderzeichen, kein regelmäßiger Zwangswechsel. Das folgt der Richtung der aktuellen Empfehlungen, auch des BSI: Länge vor Komplexität, kein Wechsel ohne Anlass; die konkreten Mindestlängen variieren je nach Schutzbedarf. Und es ist das, was Nutzer tatsächlich einhalten, statt „Sommer2024!" auf „Herbst2024!" zu ändern. Sinnvoll ist dagegen ein Abgleich gegen bekannte geleakte Passwörter, etwa über die Schnittstelle von Have I Been Pwned. Die bekommt nur die ersten fünf Zeichen eines Hashes und antwortet mit allen passenden Treffern, der Abgleich passiert bei Ihnen. Der Dienst erfährt dadurch nicht, welches Passwort geprüft wurde.
Schicht 1: Der zweite Faktor per App
Ein gestohlenes Passwort ist wertlos, wenn zum Login noch etwas fehlt, das der Dieb nicht hat. Der etablierte Weg ist ein zeitbasierter Einmalcode (TOTP): Die Anwendung und die Authenticator-App auf dem Handy teilen sich ein Geheimnis, und beide berechnen daraus alle dreißig Sekunden denselben sechsstelligen Code. Das Verfahren ist ein offener Standard (RFC 6238), funktioniert mit jeder App (Aegis, Google Authenticator, Microsoft Authenticator, Bitwarden) und braucht keine SMS und keinen Anbieter.
In PHP übernimmt das die Bibliothek spomky-labs/otphp. Die Einrichtung besteht aus drei Schritten: Geheimnis erzeugen, dem Nutzer als QR-Code zeigen, und den ersten Code prüfen, bevor der zweite Faktor scharfgeschaltet wird.
use OTPHP\TOTP;
// Einrichtung: Geheimnis erzeugen und als QR-Code anzeigen
$totp = TOTP::generate();
$totp->setLabel($nutzerEmail);
$totp->setIssuer('Kundenportal Musterbetrieb');
$secret = $totp->getSecret(); // verschlüsselt im Benutzerkonto speichern; Schlüssel dafür in der
// Anwendungskonfiguration, nicht in derselben Datenbank
$qrUrl = $totp->getProvisioningUri(); // daraus einen QR-Code rendern
// Erst scharfschalten, wenn der Nutzer einen gültigen Code eingegeben hat
if ($totp->verify($eingabe, null, 1)) { // Toleranz: 1 Zeitfenster
// 2FA für dieses Konto aktivieren, Recovery-Codes erzeugen (siehe unten)
}
// Beim Login, nach erfolgreicher Passwortprüfung. Die Bibliothek ist zustandslos:
// Replay-Schutz muss die Anwendung selbst führen (letzter akzeptierter Zeitschritt pro Konto).
$totp = TOTP::createFromSecret($secretAusDb);
$jetzt = intdiv(time(), 30);
$akzeptiert = null; // welcher Zeitschritt hat gepasst? (Toleranz ±1 Fenster)
foreach ([$jetzt - 1, $jetzt, $jetzt + 1] as $s) {
if (hash_equals($totp->at($s * 30), $eingabe)) { $akzeptiert = $s; break; }
}
if ($akzeptiert === null || $akzeptiert <= $konto->letzterTotpSchritt) {
// Fehlversuch zählen (Rate-Limit gilt auch hier), ablehnen
} else {
$konto->letzterTotpSchritt = $akzeptiert; // genau dieser Schritt ist verbraucht, wie die Captcha-Aufgabe im Formular
}
Drei Dinge, die oft schiefgehen. Das Geheimnis gehört verschlüsselt in die Datenbank, nicht im Klartext, denn wer es liest, kann Codes erzeugen; und der Schlüssel zur Verschlüsselung gehört in die Anwendungskonfiguration, nicht in dieselbe Datenbank, sonst liegt das Türschloss neben dem Schlüssel. Ein einmal verwendeter Code darf nicht noch einmal gelten, auch nicht innerhalb seines Zeitfensters: Die Bibliothek prüft nur, ob ein Code gültig ist, den Zustand „schon benutzt" muss die Anwendung führen, wie im Beispiel mit dem gespeicherten Zeitschritt. Und die Eingabe des Codes braucht dasselbe Rate-Limit wie das Passwort, sonst lassen sich sechsstellige Codes bei drei erlaubten Zeitfenstern schlicht durchprobieren.
SMS als zweiter Faktor?
Lieber nicht. SMS lassen sich durch Übernahme der Rufnummer beim Mobilfunkanbieter abfangen (SIM-Swapping), das ist in Deutschland mehrfach passiert. Auch OWASP stuft SMS unter den Faktoren ganz unten ein. Als Notfallweg für Nutzer ohne Smartphone-App ist SMS besser als nichts. Als Standard ist die App der bessere Weg, und Passkeys sind der beste.
Schicht 2: Passkeys, das Passwort loswerden
Passkeys sind der Nachfolger von Passwort plus Code, und sie sind das erste Verfahren, das für Nutzer einfacher ist als das, was es ersetzt. Beim Anlegen erzeugt das Gerät des Nutzers (Handy, Laptop, Hardware-Schlüssel) ein Schlüsselpaar. Der öffentliche Teil geht an die Anwendung, der private wird nie an die Anwendung übertragen. Beim Login beweist das Gerät mit einer Signatur, dass es den privaten Schlüssel hat, freigegeben per Fingerabdruck, Gesicht oder Geräte-PIN.
Was in vielen Erklärungen fehlt und den Stand von 2026 ausmacht: Passkeys werden heute standardmäßig synchronisiert, über den iCloud-Schlüsselbund, den Google-Passwortmanager oder Passwortmanager wie Bitwarden. Der private Schlüssel liegt damit Ende-zu-Ende-verschlüsselt beim Plattformanbieter und taucht auf dem neuen Handy wieder auf. Das hat zwei Seiten. Die gute: „Handy weg" ist für die meisten Nutzer kein Verlustfall mehr. Die unbequeme: Die Sicherheit des Kontos in Ihrem Portal hängt damit an der Sicherheit des Apple- oder Google-Kontos des Nutzers. Für ein Kundenportal ist das ein akzeptabler Tausch. Für ein Adminkonto im Controlling-System will man das wissen und kann hardwaregebundene Passkeys verlangen, etwa auf einem Sicherheitsschlüssel, die tatsächlich nicht synchronisiert werden. Das ist dieselbe Differenzierung nach Schutzbedarf wie bei der Pflicht zum zweiten Faktor: zuerst für die, die viel dürfen.
Der entscheidende Vorteil gegenüber Passwörtern und Codes: Passkeys sind an die Domain gebunden. Ein Nutzer, der auf eine gefälschte Login-Seite gelockt wird, kann dort seinen Passkey gar nicht verwenden, weil der Browser ihn nur für die echte Adresse freigibt. Phishing, die häufigste Angriffsart auf Logins, läuft ins Leere; das Verfahren gilt deshalb als phishing-resistent. Nicht als unangreifbar: Ein kompromittiertes Gerät, eine gestohlene Session oder ein schwacher Wiederherstellungsweg bleiben Angriffsflächen. Und es gibt kein Geheimnis in der Datenbank, das gestohlen werden könnte, nur öffentliche Schlüssel.
Der Standard dahinter heißt WebAuthn, seit August 2026 in Level 3 als W3C-Empfehlung, alle aktuellen Browser und Betriebssysteme unterstützen ihn. In PHP ist die Bibliothek web-auth/webauthn-lib der Stand der Dinge. Der Ablauf ist anspruchsvoller als bei TOTP, weil Browser und Server in zwei Schritten miteinander sprechen (Anfrage erzeugen, Antwort prüfen), aber die Bibliothek nimmt einem die Kryptografie ab. Was die Anwendung speichert, ist pro Passkey: eine Kennung, den öffentlichen Schlüssel, einen Signaturzähler und einen Namen, den der Nutzer vergibt („Laptop Büro", „iPhone"). Der Zähler war als Klon-Erkennung gedacht: Läuft er rückwärts, wurde der Schlüssel kopiert. Synchronisierte Passkeys liefern ihn allerdings oft konstant null, womit diese Erkennung dort faktisch entfällt. Ein weiterer Grund, für hochwertige Konten hardwaregebundene Schlüssel zu bevorzugen.
// Stark vereinfachter, pseudocode-artiger Ablauf mit web-auth/webauthn-lib.
// Die echte API ist umfangreicher und versionsabhängig; dies zeigt nur die zwei Schritte.
// 1. Server erzeugt eine Anfrage mit Zufallswert und den bekannten Passkeys des Nutzers
$options = PublicKeyCredentialRequestOptions::create(
challenge: random_bytes(32),
allowCredentials: $bekanntePasskeys, // aus der Datenbank
userVerification: 'required'
);
$_SESSION['webauthn_request'] = $options; // für Schritt 2 merken
// -> Optionen als JSON an den Browser, der ruft navigator.credentials.get() auf
// 2. Antwort des Browsers prüfen
$credential = $serializer->deserialize($jsonVomBrowser, PublicKeyCredential::class, 'json');
$source = $validator->check(
$credential->response,
$_SESSION['webauthn_request'],
'portal.musterbetrieb.de' // die Domain, an die der Passkey gebunden ist
);
// Bei Erfolg: Zähler speichern, Nutzer anmelden
Für eine Geschäftsanwendung empfehle ich Passkeys als Angebot, nicht als Zwang: Wer sie nutzt, meldet sich schneller und sicherer an. Wer ein älteres Gerät hat oder skeptisch ist, behält Passwort plus App. Nach einem Jahr sind erfahrungsgemäß die meisten regelmäßigen Nutzer umgestiegen, weil es schlicht bequemer ist.
Der Teil, der über Erfolg entscheidet: Wiederherstellung
Jeder zweite Faktor erzeugt ein neues Problem: Was passiert, wenn das Handy weg ist? Bei synchronisierten Passkeys ist das oft gar kein Verlustfall mehr, bei TOTP und hardwaregebundenen Schlüsseln sehr wohl. Wer das nicht vorher beantwortet, bekommt es als Support-Anruf, und zwar von der Geschäftsführerin des wichtigsten Kunden am Freitagnachmittag. Und ein Grundsatz steht über allem: Die Wiederherstellung ist nur ein weiterer Anmeldeweg und darf nicht schwächer sein als der normale. Ein zweiter Faktor, den man per E-Mail-Link zurücksetzen kann, ist ein E-Mail-Login. Drei Bausteine gehören deshalb von Anfang an dazu:
- Recovery-Codes. Bei der Einrichtung des zweiten Faktors bekommt der Nutzer zehn Einmalcodes zum Ausdrucken oder Speichern. Jeder Code gilt einmal, wird wie ein Passwort gehasht gespeichert, und ist lang und zufällig, denn ein sechsstelliger Code bleibt auch gehasht durchprobierbar. Das ist der Standardweg, wenn das Gerät fehlt.
- Mehrere Faktoren pro Konto. Ein Nutzer darf zwei Passkeys (Laptop und Handy) und zusätzlich eine App registrieren. Fällt eines aus, bleibt ein anderes.
- Ein definierter Prozess für den Notfall. Wenn alles weg ist: Wer darf den zweiten Faktor zurücksetzen, nach welcher Prüfung, und wird das protokolliert? In einer Geschäftsanwendung ist das meist ein Administrator beim Kunden, der die Person kennt, plus ein Eintrag im Audit-Log. Ohne diesen Prozess wird der Reset per Zuruf gemacht, und dann ist der zweite Faktor nur noch Dekoration.
Das Drumherum, das am meisten bringt
Die spektakulären Verfahren sind nur ein Teil. Der andere Teil ist unspektakulär und wird deshalb oft weggelassen:
- Rate-Limit beim Login. Nach fünf Fehlversuchen pro Konto eine wachsende Wartezeit, und pro IP-Adresse ein Limit über alle Konten. Ohne das kann ein Skript in Ruhe Passwortlisten durchprobieren. Mit Fehlermeldungen, die nicht verraten, ob das Konto existiert.
- Session-Hygiene. Nach dem Login eine neue Session-ID (
session_regenerate_id(true)), Cookies mitSecure,HttpOnlyundSameSite=Strict(Laxals Kompromiss, wenn Links aus E-Mails direkt in die Anwendung führen sollen), ein Ablauf nach Inaktivität, und ein „alle anderen Sitzungen beenden" beim Passwortwechsel. Denn nach der Anmeldung ist die Session so wertvoll wie der stärkste Faktor, mit dem sie entstanden ist. - Benachrichtigung bei neuer Anmeldung. Eine Mail „Neue Anmeldung von einem unbekannten Gerät" ist ein sehr wirkungsvoller Alarm, weil der Nutzer selbst am schnellsten weiß, ob er das war.
- Protokoll. Jeder Login, jeder Fehlversuch, jede Änderung an Faktoren mit Zeit, Adresse und Gerät. Niemals Passwörter, Codes oder Recovery-Codes, auch nicht die falschen. Und weil Adresse und Gerätekennung personenbezogen sind, mit fester Aufbewahrungsfrist. Das ist die Grundlage, um einen Vorfall aufzuklären, und der Beleg, dass die Anwendung ihre Nutzer schützt. Wie ein solches Protokoll aufgebaut ist, steht im Beitrag über Audit-Trails.
Einführung ohne Nutzer zu verlieren
Bei einer bestehenden Anwendung mit hunderten Nutzern ist die Reihenfolge entscheidend. Was sich bewährt hat:
- Zuerst das Unsichtbare: Argon2id mit Rehash beim Login, Rate-Limit, Session-Hygiene, Protokoll. Die Nutzer merken davon nichts, und die Anwendung ist deutlich sicherer.
- Dann der zweite Faktor als Angebot, mit Recovery-Codes und Anleitung. Zuerst für Administratoren und Nutzer mit weitreichenden Rechten verpflichtend, für alle anderen freiwillig.
- Passkeys anbieten, sobald der zweite Faktor läuft. Mit einem Hinweis beim Login: „Schneller anmelden ohne Passwort".
- Pflicht für alle erst mit Vorlauf, Ankündigung und einer Frist, und mit einem Notfallprozess, der schon einmal durchgespielt wurde.
Die Sicherheitsmaßnahme, die Nutzer umgehen, weil sie zu umständlich ist, schützt niemanden. Bequemlichkeit ist bei der Anmeldung ein Sicherheitsmerkmal.
Checkliste: Wie sicher ist Ihre Anmeldung?
- Werden Passwörter mit Argon2id oder bcrypt gehasht, mit kalibrierten Kostenparametern, nicht mit MD5 oder SHA?
- Gibt es ein Rate-Limit pro Konto und pro Adresse?
- Können Nutzer einen zweiten Faktor per App einrichten? Ist er für Administratoren Pflicht? Wird ein benutzter Code abgelehnt, und ist die Code-Eingabe limitiert?
- Gibt es Recovery-Codes und einen dokumentierten Notfallprozess?
- Werden Passkeys angeboten, und für hochwertige Konten hardwaregebundene?
- Ist die Wiederherstellung mindestens so stark wie die Anmeldung?
- Bekommt der Nutzer eine Nachricht bei Anmeldung von einem neuen Gerät?
- Wird die Session-ID nach dem Login erneuert, und laufen Sitzungen ab?
- Ist jede Anmeldung und jede Änderung an Faktoren protokolliert?
Fazit: Die Tür ist der Ort, an dem man anfängt
Ein Kundenportal, ein Mitarbeiterportal, ein Controlling-System: Jede dieser Anwendungen ist nur so sicher wie ihre Anmeldung. Die gute Nachricht ist, dass die richtigen Verfahren alle frei verfügbar, standardisiert und in PHP mit ausgereiften Bibliotheken umsetzbar sind. Die schlechte Nachricht ist, dass sie in den meisten bestehenden Anwendungen fehlen. Der Weg dahin geht in Etappen, beginnt mit den unsichtbaren Maßnahmen und endet damit, dass Nutzer sich schneller und sicherer anmelden als vorher. Das ist eine der wenigen Stellen, an denen mehr Sicherheit auch mehr Komfort bedeutet.
Sie haben eine Anwendung mit Login und sind nicht sicher, wie die Passwörter gespeichert sind oder ob ein zweiter Faktor fehlt? Schreiben Sie mir kurz, was es für eine Anwendung ist und wer sich dort anmeldet. Ich sehe mir die Anmeldung an und sage Ihnen, welche Etappe als Erstes dran ist.
Verwandt auf softwarearchitektin.de: Zero Trust im Mittelstand: Marketing oder Architektur?, warum Identität statt Netzwerk die Grundlage ist, und was davon Marketing bleibt.
Verwandt auf app-entwicklerin.de: App-Login: Biometrie, Passkeys, sichere Speicherung, Biometrie und Passkeys auf dem Gerät, Speicherung und typische Fehler.
Weitere interessante Artikel
Single Sign-On implementieren: SAML, OAuth, LDAP
Single Sign-On für Geschäftsanwendungen: SAML, OAuth und LDAP im Vergleich, typische Stolpersteine und wie die Anbindung in PHP gelingt.
Berechtigungssysteme mit PHP: Rollen, Rechte & Multi-Level-Zugriff
Berechtigungssysteme mit PHP: Rollen, Rechte und mehrstufiger Zugriff sauber modelliert, damit jeder nur sieht, was er sehen darf.
E-Mail-Versand aus Web-Apps: Warum Ihre Mails im Spam landen
Zuverlässiger E-Mail-Versand aus Web-Apps: SPF, DKIM, DMARC, Zustellbarkeit, Transaktionsmails.