Auth ist mehr als „Login funktioniert". Der Account-Lifecycle — Registrierung, E-Mail-Verifikation, Passwort-Reset, Re-Authentifizierung, Konto-Löschung — hat eigene Sicherheits-Anforderungen, die unabhängig von der Passwort-/Passkey-Implementierung sind. Dieser Artikel deckt die typischen Stationen ab und zeigt die durchgängige Klasse User-Enumeration: jede Endpoint-Variante, die „User existiert / existiert nicht" verrät, ist ein Aufklärungs-Vorteil für Angreifer.
User-Enumeration als Querschnitts-Klasse
User-Enumeration ist ein Angriff, bei dem der Angreifer herausfindet, ob eine bestimmte E-Mail oder Username in der App existiert. Hilft vor allem bei:
- Credential-Stuffing — wenn der Angreifer weiß, dass
alice@example.comeinen Account hat, kann er gezielt Passwort-Reuse versuchen. - Phishing-Vorbereitung — Liste valider User-Accounts für gezielte Mails.
- Account-Übernahme über Recovery — wer weiß, dass ein Account existiert, kann gezielt Reset-Mechanismen angreifen.
Typische Enumeration-Vektoren:
| Endpoint | Schadhafter Response | Sicherer Response |
|---|---|---|
| Login (User existiert nicht) | „User not found" | „Invalid credentials" |
| Login (Passwort falsch) | „Wrong password" | „Invalid credentials" |
| Registrierung (E-Mail vergeben) | „Email already in use" | „Wir haben dir eine Mail geschickt" (auch wenn schon registriert) |
| Passwort-Reset (E-Mail unbekannt) | „Email not found" | „Wenn dieses Konto existiert, haben wir eine Mail geschickt" |
| Timing-Differenz Login | DB-Lookup nur bei existierendem User | Konstante Zeit, auch Dummy-Hash-Verify |
Pragmatischer Trade-off: Komplette Enumeration-Vermeidung kostet UX (User weiß nicht, ob die Mail korrekt war). Konsens (Stand 2026): bei Login und Reset strikt generisch antworten; bei Registrierung kann ein Hinweis akzeptabel sein — wenn der Account bereits existiert, Mail an die existierende Adresse schicken („Jemand hat sich erneut mit deiner Mail registriert. Falls du das nicht warst — ignoriere.").
Registrierung mit E-Mail-Verifikation
Standard-Flow:
1. User füllt Sign-up-Formular aus (E-Mail, Passwort/Passkey).
2. Server:
- Validiert Format (E-Mail-Regex, Passwort-Stärke).
- Hasht Passwort (argon2id).
- Erzeugt User-Record mit Flag `emailVerified=false`.
- Generiert kryptografischen Verifikations-Token (z. B. 32 Bytes random).
- Speichert Token mit Expiry (24h) und userId in DB/Cache.
- Schickt Mail mit Verifikations-Link.
3. User klickt Link: /verify?token=...
4. Server:
- Token suchen, prüfen (existiert, nicht abgelaufen, nicht verbraucht).
- User-Record: emailVerified=true.
- Token invalidieren.
5. User kann sich einloggen / wird automatisch eingeloggt.Wichtige Sicherheits-Punkte:
- Token ist Bearer-Capability — wer den Token hat, kann verifizieren. Muss daher kryptografisch zufällig sein (32+ Bytes), in DB gehasht abgelegt sein (gegen DB-Leak).
- Time-Limit (typisch 24h) — alte Tokens werden invalidiert.
- One-Time-Use — nach Verifikation Token löschen.
- Funktionen vor Verifikation einschränken — App-Zugang ohne verifizierte Mail nur eingeschränkt erlauben (oder gar nicht).
Race-Condition: Wenn zwei User parallel mit derselben Mail registrieren, muss der zweite Versuch blockiert werden — DB-Unique-Index auf E-Mail-Spalte.
const crypto = require('crypto');
// Bei Sign-up
const rawToken = crypto.randomBytes(32).toString('base64url');
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
await db.verificationTokens.insert({
userId: user.id,
tokenHash,
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
});
// Mail-Link enthält rawToken
await sendMail(user.email, `https://app.example.com/verify?token=${rawToken}`);
// Bei Klick auf Link
app.get('/verify', async (req, res) => {
const hash = crypto.createHash('sha256').update(req.query.token).digest('hex');
const record = await db.verificationTokens.findOne({
tokenHash: hash,
expiresAt: { $gt: Date.now() },
});
if (!record) return res.status(400).send('Invalid or expired token');
await db.users.update({ id: record.userId }, { emailVerified: true });
await db.verificationTokens.delete({ tokenHash: hash });
res.redirect('/login');
});Token wird gehasht in der DB gespeichert — bei DB-Leak ist der Token-Pool nicht direkt nutzbar.
Passwort-Reset
Schritte:
- User klickt „Passwort vergessen?", gibt E-Mail an.
- Server: User suchen. Egal ob existiert oder nicht — generischer Response zurück („Wir haben dir eine Mail geschickt, falls das Konto existiert").
- Bei existierendem User: Reset-Token generieren (analog Sign-up), Mail schicken.
- User klickt Link, gibt neues Passwort ein.
- Server: Token validieren, Passwort updaten, Token invalidieren, alle laufenden Sessions des Users invalidieren.
Wichtige Anti-Patterns:
- Reset-Token in URL ohne Hash — wenn die URL in Browser-History, Referer-Header, Server-Logs landet, kann sie geleakt werden.
- Reset-Token mit langer Lebenszeit — 1 Stunde reicht; 24h ist großzügig; mehrere Tage sind ein Risiko.
- Reset, der nicht aktive Sessions invalidiert — Angreifer könnte schon eingeloggt sein, behält Session nach Passwort-Wechsel.
- Reset-Mail mit User-Daten im Mail-Body („Hi alice@example.com, dein Reset-Link...") — bei E-Mail-Konto-Übernahme bekommt der Angreifer wertvolle Info.
Schutz gegen Reset-Bombing: Wenn jemand Reset-Mails an fremde Adressen massenhaft auslöst, kann das ein DoS-Vektor (Mail-Provider-Spam-Flag) und ein Annoyance-Vektor sein. Rate-Limit pro Mail-Adresse und pro IP (siehe brute-force-und-rate-limits).
app.post('/forgot-password', rateLimit({ keyGenerator: req => req.body.email, max: 3, window: '1h' }),
async (req, res) => {
const user = await db.users.findOne({ email: req.body.email });
// Generischer Response — egal ob User existiert
const genericResponse = { ok: true, message: 'Falls das Konto existiert, haben wir eine Mail geschickt.' };
if (!user) return res.json(genericResponse);
const rawToken = crypto.randomBytes(32).toString('base64url');
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
await db.resetTokens.insert({
userId: user.id,
tokenHash,
expiresAt: Date.now() + 60 * 60 * 1000, // 1 Stunde
});
await sendMail(user.email, `https://app.example.com/reset?token=${rawToken}`);
res.json(genericResponse);
});
app.post('/reset', async (req, res) => {
const hash = crypto.createHash('sha256').update(req.body.token).digest('hex');
const record = await db.resetTokens.findOne({
tokenHash: hash,
expiresAt: { $gt: Date.now() },
});
if (!record) return res.status(400).send('Invalid or expired token');
const newHash = await argon2.hash(req.body.newPassword);
await db.users.update({ id: record.userId }, { passwordHash: newHash });
await db.resetTokens.delete({ tokenHash: hash });
// ALLE Sessions des Users zerstören
await sessionStore.destroyAll(record.userId);
res.json({ ok: true });
});E-Mail-Änderung
Eine oft unterschätzte Aktion. Wenn ein User die E-Mail-Adresse ändert, ist das sicherheitsrelevant — bei Account-Übernahme will der Angreifer typischerweise sofort die E-Mail wechseln, um Recovery-Pfade abzuschneiden.
Sicherer Flow:
- User gibt neue E-Mail an (im Account-Settings, mit aktivem Re-Auth).
- Server schickt Verifikations-Mail an die NEUE Adresse.
- Server schickt Notification-Mail an die ALTE Adresse: „Deine E-Mail wird auf [...] geändert. Du hast 7 Tage, das rückgängig zu machen."
- User klickt Verifikations-Link in neuer Mail → Änderung wird wirksam.
- Alte Mail-Adresse bleibt als „Last-known" markiert für 7 Tage — Rückgängigmachen ohne Auth möglich.
Pattern macht Account-Übernahme durch E-Mail-Wechsel deutlich schwerer.
Account-Löschung
Anforderungen (technisch + rechtlich):
- User-Initiierte Löschung mit Bestätigung (Re-Auth, ggf. zusätzlicher Bestätigungs-Code).
- Soft-Delete-Phase (typisch 30 Tage) für Recovery — User klickt „Doch nicht löschen" in dem Zeitraum.
- Hard-Delete danach: User-Record und PII gelöscht; nicht-PII-Daten (anonymisierte Statistik) bleiben.
- DSGVO Art. 17: Recht auf Löschung — bei berechtigtem Antrag ohne unnötige Verzögerung umsetzen.
- Audit-Log behalten: Log-Einträge mit User-ID bleiben für Compliance-Zeitraum (typisch 6 Monate bis 7 Jahre je nach Branche). User-Mapping vom User-ID-Wert zu PII wird gelöscht — Pseudonymisierung.
Pattern:
app.post('/account/delete', requireFreshAuth(), async (req, res) => {
await db.users.update({ id: req.session.userId }, {
deletionScheduledAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
status: 'pending_deletion',
});
await sessionStore.destroyAll(req.session.userId);
await sendMail(user.email, 'Konto wird in 30 Tagen gelöscht. Reaktivierung möglich.');
res.json({ ok: true });
});
// Cron-Job: tatsächliche Löschung nach Zeit
async function hardDeleteCron() {
const users = await db.users.find({
deletionScheduledAt: { $lt: Date.now() },
status: 'pending_deletion',
});
for (const user of users) {
await db.users.delete({ id: user.id });
// Audit-Log behalten, aber PII anonymisieren
await db.auditLog.update(
{ userId: user.id },
{ userEmail: '[deleted]', userName: '[deleted]' }
);
await sendMail(user.lastKnownEmail, 'Dein Konto wurde gelöscht.');
}
}Re-Authentication für sensitive Aktionen
Wie in auth-grundlagen skizziert: bestimmte Aktionen sollen erneute Auth verlangen, auch innerhalb einer aktiven Session.
Klassische Re-Auth-Ziele:
- Passwort ändern
- E-Mail ändern
- 2FA-Einstellungen ändern
- Passkey hinzufügen / entfernen
- Account löschen
- Auszahlung / Geld-Transfer
- API-Token erzeugen / widerrufen
- Admin-Aktionen (auch wenn Admin schon eingeloggt ist)
Patterns:
- Zeitfenster-basiert — Re-Auth alle 5–15 Minuten. Action-Click triggert Re-Auth-Modal wenn outside.
- Step-Up — Standard-Session reicht für normale Aktionen; Step-Up zu „authenticated_with_factor_X" nötig für sensible.
- Per-Action — bei jeder einzelnen sensitiven Aktion Bestätigung.
// Step-Up nach erfolgreichem Passkey-/2FA-Check
function markStepUp(session, level = 'high') {
session.stepUpLevel = level;
session.stepUpAt = Date.now();
}
// Middleware
function requireStepUp(level = 'high', maxAge = 5 * 60 * 1000) {
return (req, res, next) => {
const valid = req.session.stepUpLevel === level
&& (Date.now() - req.session.stepUpAt < maxAge);
if (!valid) return res.status(403).json({ error: 'StepUpRequired' });
next();
};
}
app.post('/account/email', requireStepUp('high'), async (req, res) => {
// ... E-Mail-Änderung ...
});Notification über Account-Aktivität
Sicherheits-relevante Events sollen den User per Mail benachrichtigen:
| Event | Notification |
|---|---|
| Neuer Login (ungewöhnlicher Standort/Gerät) | „Login von X um Y. Falls nicht du, klicke hier." |
| Passwort geändert | „Dein Passwort wurde geändert." |
| E-Mail geändert | An alte und neue Adresse |
| Passkey hinzugefügt / entfernt | Bestätigungs-Mail |
| 2FA aktiviert / deaktiviert | Bestätigungs-Mail |
| API-Token erstellt | „Neuer API-Token erzeugt mit Scope X." |
| Login-Versuch fehlgeschlagen (mehrfach) | Optional, gegen Brute-Force-Awareness |
Zweck: User merkt sofort, wenn jemand seinen Account übernimmt — selbst wenn der Angreifer die Auth schon kompromittiert hat, hat der User noch Zeit zu reagieren (Konto sperren, Sessions widerrufen, Support kontaktieren).
Audit-Log für Auth-Events
Pro User soll ein Audit-Log existieren, das alle sicherheits-relevanten Aktionen festhält:
- login.success { ip, userAgent, factor: "password" }
- login.failure { ip, userAgent, reason: "wrong_password" }
- password.changed { triggeredBy: "user" | "reset" | "admin" }
- email.changed { from, to }
- mfa.enabled { method: "totp" | "webauthn" }
- mfa.disabled { method }
- session.revoked { sessionId, triggeredBy }
- api_token.created { tokenId, scopes }
- account.deletion_requested
- account.deletedUser-sichtbare Variante: Account-Settings-Seite „Letzte Aktivität" zeigt die wichtigsten Einträge. Google macht es vor.
Server-interne Variante: detailliertere Logs (IP, User-Agent, Risiko-Score) — für Forensik und Anomalie-Detection.
Interessantes
DSGVO Art. 5 — Datenminimierung beim Account-Lifecycle
Welche User-Daten brauchst du? Minimum: E-Mail (oder Username), Passwort-Hash. Optional: Name, Adresse, Telefon. Pro Feld die Frage „brauche ich das wirklich?". DSGVO-Grundprinzip: erhebe nur, was nötig ist. Anwender-Daten, die du nicht hast, kannst du auch nicht leaken.
"Username available?"-Endpoint als Enumeration-Vektor
Real-Time-Check „Ist dieser Username/diese Mail noch frei?" ist UX-freundlich, aber Enumeration-perfekt. Kompromiss: Check nur nach Login zulassen (Konto-Settings: User-Namen ändern), nicht auf der öffentlichen Registrierungs-Seite. Oder: Server liefert generischen Vorschlag ohne klares Ja/Nein.
Bot-Schutz im Sign-up — gegen Mail-Bombing
Wenn Registrierung ohne Bot-Schutz, kann ein Angreifer massenhaft Accounts mit fremden Mail-Adressen anlegen — die Verifikations-Mails landen in den Postfächern der Opfer. CAPTCHA, Rate-Limit pro IP, hCaptcha/Cloudflare-Turnstile als Standard-Werkzeuge. Mail-Bombing ist ein verbreiteter Belästigungs-Vektor.
Welcome-Mail mit eingeschränkter Click-Through-Rate
Phishing-Schutz-Tipp: in der Welcome-Mail / Reset-Mail klar markieren, was die App tut und was nicht. „Wir werden dich NIEMALS nach deinem Passwort fragen. Wir verlangen NIEMALS Sofort-Logins über externe Links außer Reset/Verifikation." Macht spätere Phishing-Mails leichter erkennbar.
Soft-Delete vs. Hard-Delete-Trade-off
Soft-Delete (User-Record bleibt, Flag „deleted=true") ist technisch einfacher, aber DSGVO-konform nur, wenn echte Personen-bezogene Daten innerhalb der Frist hart gelöscht werden. Pattern: Soft-Delete für 30 Tage (Recovery-Phase), danach Hard-Delete von PII, pseudonymisierter Audit-Log bleibt. Frist und Pseudonymisierungs-Methode dokumentieren.
Konto-Inaktivität als Sicherheits-Maßnahme
Apps wie Google löschen inaktive Accounts nach 2 Jahren (mit vorheriger Notification). Reduziert Angriffsfläche: weniger schlafende Accounts mit alten Credentials. Für reguläre Apps ein bisschen viel — aber für Hoch-Sicherheits-Apps (Banking, Identity) wert nachzudenken. Mindestens: Notification bei langem Inactive plus Re-Auth-Verlangen beim Wiederkehr.
Magic Link statt Passwort
„Magic Link" ist ein Pattern, das die Auth-Stärke an die Mail-Account-Sicherheit knüpft: User gibt Mail an, bekommt Login-Link, klickt — drin. Kein Passwort. Vorteil: kein Passwort-Storage, kein Reset-Flow. Nachteil: Phishing-anfällig (Link ist abfangbar bei Mail-Account-Übernahme), MFA-feindlich. Sinnvoll als zusätzliche Option neben Passkey, selten als einzige.
Weiterführende Ressourcen
Externe Quellen
- OWASP Forgot Password Cheat Sheet
- OWASP Authentication Cheat Sheet
- OWASP User Privacy Protection
- NIST SP 800-63B — Memorized Secret / Reset
- DSGVO Art. 17 — Recht auf Löschung
- DSGVO Art. 5 — Grundsätze