Passwörter im Klartext speichern ist seit den 1970ern als Fehler bekannt. Aber auch der nächste Schritt — schnelle Hashes wie MD5 oder SHA-256 — ist in der Passwort-Welt strukturell falsch. Moderne Passwort-Hashes (argon2id, bcrypt, scrypt) sind bewusst langsam und speicher-aufwendig, um Brute-Force-Angriffe mit GPUs und ASICs unwirtschaftlich zu machen. Dieser Artikel zeigt, welcher Hash für was, wie Parameter gewählt werden, und welche Migration-Strategien beim Algorithmus-Wechsel funktionieren.
Warum schnelle Hashes für Passwörter falsch sind
SHA-256, SHA-3, BLAKE3 — alles kryptografisch saubere Hash-Funktionen. Für Passwort-Storage sind sie trotzdem das Falsche.
Grund: Sie sind schnell. Eine moderne GPU rechnet Milliarden SHA-256-Hashes pro Sekunde. Wenn ein Angreifer den Hash-Dump bekommt (DB-Leak), kann er:
- Wörterbuch-Angriffe mit Millionen Wörtern in Sekunden durchprobieren.
- Brute-Force über alle 8-stelligen Passwörter in Stunden.
- Rainbow-Tables vorberechnen (für ungesaltete Hashes).
Beispiel-Zahlen (2026, Single RTX 4090):
| Hash | Geschwindigkeit (Hashes/s) | Angriff auf 8-stelliges Passwort (a-z + 0-9) |
|---|---|---|
| MD5 | ~110 Mrd. | ~30 Sekunden |
| SHA-1 | ~50 Mrd. | ~1 Minute |
| SHA-256 | ~13 Mrd. | ~4 Minuten |
| bcrypt (cost=12) | ~25 Tsd. | ~25 Jahre |
| argon2id (default) | ~5 Tsd. | ~125 Jahre |
Der entscheidende Schritt: 6 Größenordnungen langsamer als SHA-256. Das macht Brute-Force von ratbaren Passwörtern unwirtschaftlich — und das ist genau das Ziel.
Salt — gegen Rainbow-Tables und Hash-Kollision-Wiederverwendung
Ein Salt ist ein zufälliger Wert, der zum Passwort vor dem Hashing addiert wird. Pro User ein anderer Salt.
Was Salt verhindert:
- Rainbow-Tables (vorberechnete Hash → Passwort-Tabellen) sind nutzlos, weil jeder User einen anderen Salt hat.
- Hash-Kollision-Erkennung im Dump — wenn zwei User das gleiche Passwort haben, sehen ihre Hashes trotzdem unterschiedlich aus. Ohne Salt wäre erkennbar, welche User dasselbe (eventuell schwache) Passwort nutzen.
Salt-Pflicht-Eigenschaften:
- Pro User einzigartig (zufällig generiert).
- Mindestens 128 Bit Entropie (16 Bytes).
- Mit dem Hash zusammen gespeichert — Salt ist kein Geheimnis, muss aber persistent sein, um Verifikation zu erlauben.
Gute Nachricht: Alle modernen Passwort-Hash-Funktionen (argon2id, bcrypt, scrypt, PBKDF2) erzeugen den Salt automatisch und packen ihn in den Output-String. Du musst nichts selbst tun, solange du die Library-API korrekt nutzt.
Beispiel argon2id-Output:
$argon2id$v=19$m=65536,t=3,p=4$randomsalt$hashvalue
│ │ │ │ │
│ │ │ │ └─ Hash (Base64)
│ │ │ └─ Salt (Base64)
│ │ └─ Parameter (Memory=64MB, Iterations=3, Parallelism=4)
│ └─ Version
└─ AlgorithmusSalt und Parameter sind im String enthalten — die DB-Spalte speichert nur diesen String, kein separates Salt-Feld.
argon2id als moderner Default
argon2id ist der Gewinner der Password Hashing Competition (2015) und der aktuelle Empfehlungs-Default von OWASP und IETF (RFC 9106).
Eigenschaften:
- Speicher-hart — braucht viel RAM (~64 MB Default), GPUs/ASICs haben wenig schnellen RAM.
- Drei Parameter:
memory(KB),iterations(Rechen-Zeit),parallelism(Threads). - Drei Varianten: argon2d (datenabhängige Speicher-Zugriffe), argon2i (datenunabhängig), argon2id (Hybrid, Default).
Node.js (argon2-Package):
const argon2 = require('argon2');
// Hashen beim Registrieren
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 Iterations
parallelism: 4,
});
// Speichere `hash` in DB — Salt ist im String enthalten
// Verifizieren beim Login
const ok = await argon2.verify(hash, password);Python (argon2-cffi):
from argon2 import PasswordHasher
ph = PasswordHasher(
memory_cost=65536,
time_cost=3,
parallelism=4,
)
hash = ph.hash(password)
ph.verify(hash, password) # wirft VerifyMismatchError bei falschGo (crypto/argon2):
import "golang.org/x/crypto/argon2"
// Salt selbst generieren (Standard-Lib hat keine integrierte API)
salt := make([]byte, 16)
rand.Read(salt)
hash := argon2.IDKey([]byte(password), salt,
3, // iterations
64*1024, // memory in KB
4, // parallelism
32) // key lengthPHP (built-in seit 7.2):
// PHP nutzt argon2id mit eigenen Defaults
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536,
'time_cost' => 3,
'threads' => 4,
]);
$ok = password_verify($password, $hash);Parameter-Empfehlung (OWASP 2026):
memoryCost: mindestens 19 MiB (19456) für AAL1; 64 MiB (65536) für höhere Sicherheit.timeCost: mindestens 2 Iterations für 19 MiB, 3+ für 64 MiB.parallelism: 1–4 Threads (je nach CPU-Auslastung).- Test-Faustregel: Hashing soll ~250–500 ms pro Login dauern. Schneller → Parameter erhöhen. Langsamer → User-Erfahrung leidet, Parameter senken.
bcrypt als robuster Fallback
bcrypt ist seit 1999 etabliert, sehr gut auditiert, in fast jeder Sprache nativ verfügbar. Schwächere Speicher-Härte als argon2id, aber für die meisten Apps absolut ausreichend.
Eigenschaften:
- Cost-Faktor als einziger Parameter (logarithmisch: cost=12 = 2^12 = 4096 Iterations).
- Eingebaute 72-Byte-Begrenzung des Passworts — längere Strings werden abgeschnitten (Pre-Hash mit SHA-256 als Workaround, siehe Pitfalls).
- Native APIs in fast allen Sprachen.
Node.js:
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash(password, 12); // cost=12
const ok = await bcrypt.compare(password, hash);Python:
import bcrypt
hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
ok = bcrypt.checkpw(password.encode(), hash)Go:
import "golang.org/x/crypto/bcrypt"
hash, _ := bcrypt.GenerateFromPassword([]byte(password), 12)
err := bcrypt.CompareHashAndPassword(hash, []byte(password))Cost-Empfehlung (2026):
- cost=12 als sicherer Default (~250 ms auf modernem Server).
- cost=14 für sensitive Apps (Banking, Identity).
- cost=10 ist veraltet — auf 2010er-Hardware adäquat, heute zu schnell.
scrypt und PBKDF2
scrypt ist ähnlich wie argon2id (speicher-hart), aber älter (2009) und mit etwas anderem Memory-Profile. Wo verfügbar (Node.js eingebaut, Python passlib), gut nutzbar.
const crypto = require('crypto');
// Built-in scrypt in Node.js (kein npm-Package nötig)
const salt = crypto.randomBytes(16);
const N = 2 ** 16; // 65536 — Cost
const r = 8;
const p = 1;
const keyLen = 32;
const hash = crypto.scryptSync(password, salt, keyLen, { N, r, p });
// Salt separat speichern, Format selbst zusammenbauenPBKDF2 ist die älteste der modernen Optionen (RFC 2898, 2000). Nicht speicher-hart, aber FIPS 140-3-zertifiziert — daher in Compliance-Umfeldern (US-Behörden, Gesundheitswesen) Pflicht.
import hashlib
import os
salt = os.urandom(16)
# NIST SP 800-132 Empfehlung: 600.000+ Iterations (2026)
hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600_000)
# Salt + Iterations + Hash separat speichernWann was wählen:
| Algorithmus | Wann | Stand 2026 |
|---|---|---|
| argon2id | Default für Neubau | Empfehlung von OWASP, IETF |
| bcrypt | Wenn argon2-Library nicht verfügbar | Robust, gut auditiert, Legacy-kompatibel |
| scrypt | Wenn Speicher-Härte gewünscht und argon2 nicht passt | Selten gewählt |
| PBKDF2 | Wenn FIPS-Compliance Pflicht | Mindeststandard, nicht erste Wahl |
| SHA-256/SHA-3/BLAKE3 | NIEMALS für Passwörter | Nur für allgemeines Hashing |
Pepper — die optionale extra Schicht
Pepper ist ein server-seitiges Geheimnis, das zusätzlich zum Salt in den Hash einfließt. Im Gegensatz zum Salt wird Pepper nicht zusammen mit dem Hash gespeichert — wenn die DB geleakt wird, fehlt dem Angreifer der Pepper.
Implementierungs-Varianten:
const crypto = require('crypto');
const argon2 = require('argon2');
// Pepper aus Umgebungs-Variable (oder besser: KMS/HSM)
const PEPPER = process.env.PASSWORD_PEPPER;
function preHashWithPepper(password) {
return crypto.createHmac('sha256', PEPPER)
.update(password)
.digest('hex');
}
// Hashen
const pepperedPassword = preHashWithPepper(password);
const hash = await argon2.hash(pepperedPassword);
// Verifizieren
const ok = await argon2.verify(hash, preHashWithPepper(password));Trade-offs:
- Vorteil: Bei reinem DB-Leak (ohne App-Server-Kompromittierung) sind die Hashes unbrauchbar.
- Nachteil: Pepper-Verlust ist katastrophal — alle User müssen neue Passwörter setzen, Hashes sind unbrauchbar.
- Nachteil: Pepper-Rotation ist schwer (alle Hashes müssen beim nächsten Login re-hashed werden).
Konsens: Pepper ist optional und nur für sensitive Apps wert. Standard-Hashing mit argon2id + Salt reicht für die meisten Use-Cases. Pepper ist Defense-in-Depth, kein Ersatz für richtige Hash-Wahl.
Algorithmus-Migration
Wenn die App von bcrypt auf argon2id wechselt (oder Cost-Faktor erhöht): on-the-fly beim Login migrieren.
Pattern:
const bcrypt = require('bcrypt');
const argon2 = require('argon2');
async function verifyAndMaybeRehash(user, password) {
const hash = user.passwordHash;
let valid;
if (hash.startsWith('$argon2')) {
valid = await argon2.verify(hash, password);
// Bei alten argon2-Params: re-hash mit aktuellen Params
if (valid && argon2.needsRehash(hash, CURRENT_PARAMS)) {
const newHash = await argon2.hash(password, CURRENT_PARAMS);
await db.users.update({ id: user.id }, { passwordHash: newHash });
}
} else if (hash.startsWith('$2')) {
// bcrypt
valid = await bcrypt.compare(password, hash);
// Migration zu argon2id
if (valid) {
const newHash = await argon2.hash(password);
await db.users.update({ id: user.id }, { passwordHash: newHash });
}
}
return valid;
}Vorteile:
- Keine Big-Bang-Migration — User migrieren beim normalen Login.
- Aktive User profitieren sofort, inaktive User irrelevant.
- Keine Klartext-Passwort-Speicherung für Migration nötig (das war ein historischer Anti-Pattern).
Was, wenn ein User sich nie wieder einloggt? Sein Hash bleibt im alten Format. Bei DB-Leak ist er etwas schwächer — aber die Wahrscheinlichkeit, dass jemand mit jahrelang ungenutztem Account angegriffen wird, ist gering. Akzeptabel.
Test-Strategien
Performance-Test:
Hash-Dauer auf der Ziel-Hardware messen — argon2id mit zu hohen Parametern macht Login langsam, mit zu niedrigen unsicher.
const argon2 = require('argon2');
async function benchmark() {
const password = 'test-password-123';
const start = Date.now();
await argon2.hash(password, {
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
console.log(`Hash dauert: ${Date.now() - start} ms`);
}
// Ziel: 250–500 ms auf Production-HardwareStatic-Analysis:
- Semgrep mit Pattern
crypto.createHash('md5'|sha1|sha256').update(password)für Anti-Pattern-Detection. - Gitleaks / trufflehog für Klartext-Passwörter in Code-History.
Code-Review-Pattern:
Grep nach:
md5,sha1,sha256in der Nähe vonpassword.crypto.createHashmit User-Input.- Eigenbau-Salting (selbst-gebaute
password + salt-Strings). password_hashmitPASSWORD_DEFAULT(PHP) — checken, was Default ist (PHP 7.x: bcrypt; PHP 8+: bleibt bcrypt, argon2id explizit setzen).
Häufige Stolperfallen
bcrypt schneidet Passwörter bei 72 Bytes ab
bcrypt verarbeitet maximal 72 Bytes Input — alles darüber wird stillschweigend abgeschnitten. Zwei verschiedene Passwörter mit identischen ersten 72 Bytes haben den gleichen Hash. Schutz: vor dem bcrypt-Aufruf Passwort mit SHA-256 prehashen (Pre-Hash zu 32 Bytes Hex = 64 Zeichen, sicher unter 72). Achtung: dann müssen alle Hash-Routinen denselben Pre-Hash machen, sonst Login-Bruch.
argon2-Params zu hoch = DoS-Vektor auf den Login-Endpoint
Wenn Hashing 2 Sekunden dauert, kann ein:e Angreifer:in mit 10 parallelen Requests den App-Server ausbremsen. Login-Endpoint ohne Rate-Limit + hohe Hash-Params = einfaches DoS. Rate-Limiting (siehe brute-force-und-rate-limits) ist nicht nur gegen Brute-Force nötig, sondern auch gegen Hash-Bombing.
Eigenes Salting / Pepper-Konkatenation ist eine Bug-Klasse
Pattern wie sha256(password + salt) oder sha256(salt + password) sind keine sicheren Konstruktionen — Length-Extension-Angriffe, Type-Confusion bei verschiedenen Sprachen (UTF-8 vs. Bytes), Salt-Reuse durch Kopier-Fehler. Always: Library-API nutzen, niemals eigene Konstruktion.
Hash-Funktion-Konstanten-Vergleich (Timing)
Beim Hash-Vergleich Library-API verwenden — die machen konstante-Zeit-Vergleiche. argon2.verify() und bcrypt.compare() sind konstante Zeit. Eigene Vergleiche mit === oder == sind Timing-Side-Channel.
Klartext-Passwörter in Logs sind ein Klassiker
Login-Request-Body in Debug-Logs, Stack-Traces mit Local-Variables, Error-Reporter (Sentry, Rollbar) — alles potenzielle Stellen, wo das Klartext-Passwort hingelangen kann. Konsequenter Filter: Passwort-Feld vor Logging immer redacten (Express: req.body.password = '[REDACTED]' vor Log; Sentry: beforeSend-Hook).
"Geheime Sicherheits-Fragen" sind schwache Hashes
Mädchenname der Mutter, Geburtsstadt — Antworten sind oft öffentlich (Social Media) und werden meist nicht gehasht, sondern in Klartext oder mit unzulänglichem Hash gespeichert. Sicherheits-Fragen sind durchgängig veraltet; ersetzen durch echte MFA. Wenn Recovery-Frage zwingend ist, dann mit argon2id hashen wie ein Passwort.
PASSWORD_DEFAULT in PHP ist bcrypt, nicht argon2id
PHP's password_hash($pw, PASSWORD_DEFAULT) nutzt seit PHP 5.5 bcrypt — auch in PHP 8+. argon2id ist mit PASSWORD_ARGON2ID explizit zu wählen. Wer auf „PHP entscheidet schon das Richtige" hofft, bleibt bei bcrypt — das ist okay, aber bewusste Entscheidung sollte es sein.
Weiterführende Ressourcen
Externe Quellen
- OWASP Password Storage Cheat Sheet
- RFC 9106 — Argon2 Memory-Hard Function for Password Hashing
- RFC 2898 — PBKDF2
- NIST SP 800-63B — Memorized Secret Authenticator
- Password Hashing Competition (2013–2015)
- argon2 Reference Implementation
- argon2 (Node.js)
- argon2-cffi (Python)
- Openwall PHC-Status
Verwandte Artikel
- Auth-Grundlagen
- Session-Cookies
- Brute-Force und Rate-Limits
- Account-Lifecycle
- Crypto-Grundregeln (Kap 17)
- Hashing-Übersicht (Kap 17 — generelle Hashes)