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):

HashGeschwindigkeit (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:

Plain argon2id-output-format.txt
$argon2id$v=19$m=65536,t=3,p=4$randomsalt$hashvalue
│        │    │              │         │
│        │    │              │         └─ Hash (Base64)
│        │    │              └─ Salt (Base64)
│        │    └─ Parameter (Memory=64MB, Iterations=3, Parallelism=4)
│        └─ Version
└─ Algorithmus

Salt 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):

JavaScript argon2id-node.js
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):

Python argon2id-python.py
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 falsch

Go (crypto/argon2):

Go argon2id-go.go
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 length

PHP (built-in seit 7.2):

PHP argon2id-php.php
// 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:

JavaScript bcrypt-node.js
const bcrypt = require('bcrypt');

const hash = await bcrypt.hash(password, 12);  // cost=12
const ok = await bcrypt.compare(password, hash);

Python:

Python bcrypt-python.py
import bcrypt

hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
ok = bcrypt.checkpw(password.encode(), hash)

Go:

Go bcrypt-go.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.

JavaScript scrypt-node.js
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 zusammenbauen

PBKDF2 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.

Python pbkdf2-python.py
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 speichern

Wann was wählen:

AlgorithmusWannStand 2026
argon2idDefault für NeubauEmpfehlung von OWASP, IETF
bcryptWenn argon2-Library nicht verfügbarRobust, gut auditiert, Legacy-kompatibel
scryptWenn Speicher-Härte gewünscht und argon2 nicht passtSelten gewählt
PBKDF2Wenn FIPS-Compliance PflichtMindeststandard, nicht erste Wahl
SHA-256/SHA-3/BLAKE3NIEMALS für PasswörterNur 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:

JavaScript pepper-hmac-pattern.js
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:

JavaScript hash-migration-pattern.js
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.

JavaScript hash-benchmark.js
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-Hardware

Static-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, sha256 in der Nähe von password.
  • crypto.createHash mit User-Input.
  • Eigenbau-Salting (selbst-gebaute password + salt-Strings).
  • password_hash mit PASSWORD_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

/ Weiter

Zurück zu Authentifizierung (Entwickler)

Zur Übersicht