Passwörter richtig speichern: bcrypt und argon2
1. Überblick
Das Speichern von Passwörtern auf unsichere Weise ist eine der häufigsten Sicherheitslücken, die zu Datenlecks führen, wenn die Datenbank angegriffen wird. Dieser Artikel hilft Ihnen zu verstehen, warum Sie nicht einfaches MD5/SHA256 zum Hashen von Passwörtern verwenden sollten, und wie Sie BCryptPasswordEncoder und Argon2PasswordEncoder in Spring Security nutzen. Nach der Lektüre können Sie einen sicheren PasswordEncoder konfigurieren, Tests schreiben, um das Verhalten zu überprüfen, und wissen, welchen Algorithmus Sie für Ihr Projekt wählen sollten.
2. Warum Sie Passwörter nicht mit reinem SHA-256 hashen sollten
Kryptografische Hash-Funktionen wie SHA-256 und SHA-512 sind dafür ausgelegt, so schnell wie möglich zu laufen. Das ist ein Vorteil beim Überprüfen der Dateiintegrität, aber ein Desaster für Passwörter.
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
public class NaiveHasher {
public static String sha256(String password) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(hash);
}
}
Die obige Funktion kann Milliarden von Passwörtern pro Sekunde auf einer mittelklassigen GPU hashen. Angreifer können mit einer Rainbow-Table oder Brute-Force-Attacke leicht das ursprüngliche Passwort herausfinden, besonders bei häufigen Passwörtern wie 123456, password1.
Die zwei kritischen Probleme sind:
- Kein Salt: Zwei Benutzer mit demselben Passwort erhalten denselben Hash, was Rainbow-Table-Angriffe ermöglicht.
- Zu schnell: Es gibt keine Möglichkeit, Angreifer zu verlangsamen.
Hinweis: Fügen Sie nicht manuell Salt zu SHA-256 hinzu. Das ist immer noch eine schwache Lösung, da der „Work Factor” fehlt — die Schwierigkeit steigt nicht mit stärkerer Hardware.
3. Was ist eine Password-Hashing-Funktion
Algorithmen wie bcrypt, scrypt und argon2 sind speziell zum Hashen von Passwörtern konzipiert und haben drei Eigenschaften:
- Automatisches Salt: Jeder Hash wird mit einem neuen, zufälligen Salt generiert, das in der Ausgabe eingebettet ist.
- Absichtlich langsam (Work Factor): Parameter, um die Schwierigkeit zu erhöhen und Brute-Force-Attacken zu widerstehen, auch wenn Hardware stärker wird.
- Memory-hard (bei argon2/scrypt): Benötigt viel RAM, was parallele Ausführungen auf GPU/ASIC erschwert.
Das Ergebnis eines bcrypt-Hashes sieht so aus:
$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
Struktur: $<version>$<cost>$<salt+hash base64>. Hier ist 10 der Cost-Faktor (2^10 Iterationen).
4. Bcrypt mit Spring Security verwenden
4.1. Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
BCryptPasswordEncoder ist bereits in spring-security-crypto enthalten, Sie benötigen nichts Weiteres.
4.2. Bean-Deklaration
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // Cost-Faktor = 12
}
}
Der Parameter 12 bestimmt die Anzahl der Iterationen: 2^12. Ein höherer Cost-Faktor → langsamer → sicherer, verbraucht aber auch mehr CPU beim Login. Werte von 10–12 sind für die meisten Anwendungen 2026 angemessen.
4.3. Verwendung bei Registrierung und Login
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class UserRegistrationService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
public UserRegistrationService(PasswordEncoder passwordEncoder, UserRepository userRepository) {
this.passwordEncoder = passwordEncoder;
this.userRepository = userRepository;
}
public void register(String email, String rawPassword) {
String hashed = passwordEncoder.encode(rawPassword);
userRepository.save(new Customer(email, hashed));
}
public boolean authenticate(String email, String rawPassword) {
Customer customer = userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("E-Mail existiert nicht"));
return passwordEncoder.matches(rawPassword, customer.getPasswordHash());
}
}
encode()generiert jedes Mal ein neues, zufälliges Salt, daher erzeugt dasselberawPasswordjedes Mal einen anderen Hash — dies ist korrektes Verhalten, kein Bug.matches()extrahiert automatisch das Salt aus dem gespeicherten Hash, hasht das eingegebene Passwort neu und vergleicht — Sie müssen das Salt nicht selbst verwalten.
4.4. Test-Verhalten
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class BcryptPasswordEncoderTest {
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
@Test
void shouldGenerateDifferentHashesForSamePassword() {
String hash1 = encoder.encode("S3cretPass!");
String hash2 = encoder.encode("S3cretPass!");
assertThat(hash1).isNotEqualTo(hash2);
assertThat(encoder.matches("S3cretPass!", hash1)).isTrue();
assertThat(encoder.matches("S3cretPass!", hash2)).isTrue();
}
@Test
void shouldRejectWrongPassword() {
String hash = encoder.encode("S3cretPass!");
assertThat(encoder.matches("WrongPass!", hash)).isFalse();
}
}
Dieser Test bestätigt zwei wichtige Dinge: Random Salt führt zu unterschiedlichen Hashes, und matches() erkennt das ursprüngliche Passwort immer noch korrekt, obwohl die Hashes unterschiedlich sind.
Hinweis: bcrypt hat ein Input-Längenlimit von 72 Bytes. Wenn das Passwort länger ist, wird der Rest stillschweigend abgeschnitten. Bei normalen Passwörtern (unter 72 Zeichen) ist das kein Problem, aber wenn Sie sehr lange Passphrasen erlauben, sollten Sie argon2 in Betracht ziehen.
5. Argon2 — die modernere Wahl
Argon2 gewann den Password Hashing Competition 2015 und wird von OWASP als erste Wahl empfohlen, da es besser gegen GPU-Cracking-Angriffe schützt als bcrypt (memory-hard).
5.1. Dependency
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
Spring Security verwendet Bouncy Castle als Provider für Argon2PasswordEncoder, daher benötigen Sie diese zusätzliche Dependency.
5.2. Bean-Deklaration
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// saltLength=16, hashLength=32, Parallelismus=1, Memory=1<<16 (64MB), Iterationen=3
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
}
}
Die Factory-Methode defaultsForSpringSecurity_v5_8() gibt eine Parameterkonfiguration zurück, die vom Spring-Security-Team als sicher für die meisten Anwendungen überprüft wurde. Sie müssen Memory/Iterationen nicht selbst berechnen.
5.3. Ausgabe vergleichen
$argon2id$v=19$m=65536,t=3,p=1$c29tZXNhbHQ$5t1h3u7K3iC2rT0...
Die Struktur besteht aus Variante (argon2id), Version, Memory-Parameter (m), Iterationen (t), Parallelismus (p) und dann Salt und Hash — transparenter als bcrypt, da die Kosten deutlich angezeigt werden.
5.4. Test zum Vergleich der beiden Encoder
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class Argon2PasswordEncoderTest {
private final Argon2PasswordEncoder encoder =
Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
@Test
void shouldVerifyCorrectPassword() {
String hash = encoder.encode("MyP@ssw0rd123");
assertThat(encoder.matches("MyP@ssw0rd123", hash)).isTrue();
assertThat(encoder.matches("wrong", hash)).isFalse();
}
}
6. Mehrere Encoder gleichzeitig mit DelegatingPasswordEncoder unterstützen
In der Praxis müssen Sie möglicherweise den Algorithmus im Laufe der Zeit ändern (z. B. von bcrypt zu argon2 migrieren), ohne alte Hashes zu zerstören. Spring Security bietet hierfür eine vorgefertigte Lösung.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
createDelegatingPasswordEncoder() erstellt einen Encoder, der Präfixe wie {bcrypt}, {argon2}, {noop}… in der Hash-Zeichenkette lesen kann, um den richtigen Algorithmus zum Dekodieren zu wählen. Standardmäßig verwendet er bcrypt zum Codieren neuer Passwörter, passt sich aber auch matches() an alte Hashes an, die mit anderen Algorithmen erstellt wurden.
{bcrypt}$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
{argon2}$argon2id$v=19$m=65536,t=3,p=1$...
Mit diesem Präfix können Sie schrittweise migrieren: Alte Benutzer können sich weiterhin anmelden, neue Benutzer oder solche, die Passwörter ändern, werden mit dem neuesten Algorithmus neu gehashed.
7. Schnellvergleich: bcrypt vs. argon2
| Kriterium | bcrypt | argon2 |
|---|---|---|
| Erscheinungsjahr | 1999 | 2015 (PHC-Gewinner) |
| Schutz vor GPU/ASIC | Gut | Besser (memory-hard) |
| Input-Längenlimit | 72 Byte | Keine praktische Grenze |
| Verbreitung | Sehr verbreitet | Zunehmend verbreitet |
| OWASP-Empfehlung | Zweite Wahl | Erste Wahl |
Für neue Projekte ohne Einschränkungen durch ältere Systeme wählen Sie argon2id (die argon2id-Variante ist der Standard in Spring Security und bietet einen Kompromiss zwischen Schutz vor Side-Channel- und GPU-Angriffen). Wenn Ihr System bereits stabil bcrypt nutzt, müssen Sie nicht sofort migrieren — die Erhöhung des bcrypt-Cost-Faktors auf 12–14 ist für die meisten Anwendungen immer noch sicher.
8. Häufige Fehler
- Selbstständig Salt + SHA-256 implementieren: Wie in Abschnitt 2 erwähnt, fehlt der Work Factor, weshalb dies immer noch anfällig für modernes Brute-Force ist.
- Zu niedrigen Cost-Faktor verwenden:
BCryptPasswordEncoder()hat ohne Parameter einen Standard-Cost von 10, aber wenn Sie ihn während Tests auf 4–5 reduzieren für „Geschwindigkeit”, vergessen Sie leicht, ihn in der Produktion zu ändern. - Hashes mit
String.equals()vergleichen: Vergleichen Sie Hash-Zeichenketten niemals manuell — verwenden Sie immermatches()vonPasswordEncoder, da diese Bibliotheken einen Constant-Time-Vergleich durchführen, um Timing-Attacken zu vermeiden. - Salt in einer separaten DB-Spalte speichern: Unnötig, sowohl bcrypt als auch argon2 betten das Salt in die Hash-Ausgabe ein.
- Keine Rate-Limiting beim Login: Egal wie stark die Hashes sind, ohne Rate Limiting oder Account Lockout ist Online-Brute-Force immer noch möglich.
Hinweis: Verwenden Sie niemals den
{noop}-Encoder (Klartext) in der Produktion. Er sollte nur in Tests oder lokalen Demos auftreten.
9. Fazit
Das richtige Hashen von Passwörtern bedeutet, einen Algorithmus zu verwenden, der speziell dafür konzipiert wurde — bcrypt oder argon2 — anstelle von schnellen Hash-Funktionen wie SHA-256. Spring Security macht dies einfach: Deklarieren Sie einfach ein PasswordEncoder-Bean, rufen Sie encode() beim Speichern und matches() bei der Authentifizierung auf, ohne Salt selbst zu verwalten. Wenn Sie flexibel zwischen Algorithmen migrieren müssen, ist DelegatingPasswordEncoder die sichere Wahl mit Rückwärtskompatibilität. Ob Sie bcrypt oder argon2 wählen, denken Sie daran, dass starkes Hashing nur eine Schutzebene ist — erst die Kombination mit Rate Limiting und MFA schafft ein wirklich sicheres Authentifizierungssystem.