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.com einen 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:

EndpointSchadhafter ResponseSicherer 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 LoginDB-Lookup nur bei existierendem UserKonstante 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:

Plain signup-flow.txt
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.

JavaScript signup-token-storage.js
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:

  1. User klickt „Passwort vergessen?", gibt E-Mail an.
  2. Server: User suchen. Egal ob existiert oder nicht — generischer Response zurück („Wir haben dir eine Mail geschickt, falls das Konto existiert").
  3. Bei existierendem User: Reset-Token generieren (analog Sign-up), Mail schicken.
  4. User klickt Link, gibt neues Passwort ein.
  5. 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).

JavaScript password-reset-flow.js
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:

  1. User gibt neue E-Mail an (im Account-Settings, mit aktivem Re-Auth).
  2. Server schickt Verifikations-Mail an die NEUE Adresse.
  3. 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."
  4. User klickt Verifikations-Link in neuer Mail → Änderung wird wirksam.
  5. 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:

JavaScript account-deletion-flow.js
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.
JavaScript step-up-pattern.js
// 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:

EventNotification
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ändertAn alte und neue Adresse
Passkey hinzugefügt / entferntBestätigungs-Mail
2FA aktiviert / deaktiviertBestä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:

Plain audit-log-events.txt
- 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.deleted

User-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

/ Weiter

Zurück zu Authentifizierung (Entwickler)

Zur Übersicht