Mein erster Versuch, eine Verifizierungs-Mail aus AWS Cognito zu schicken, war ein eigener SMTP-Server. Komplettes CDK-Stack, EC2-Instanz, Postfix-Konfiguration, alles. Lokal lief es. In der AWS-Cloud kam keine einzige Mail durch. Drei Fallen später lief das System produktiv. Hier die Geschichte, die Architektur, und was ich dabei gelernt habe.
Das DACH-Onboarding-Problem
AWS Cognito kommt out-of-the-box mit funktionierendem E-Mail-Versand. Bei Sign-up, Verifizierung, Passwort-Reset versendet der Service automatisch. Klingt fertig. Bis du die erste Mail im Posteingang siehst.
Absender: no-reply@verificationemail.com. Sprache: Englisch, hartcodiert. Layout: spartanischer Plain-Text mit einem Code. Branding: keines. Für ein DACH-SaaS-Produkt ist das ein Conversion-Killer. Ein Schweizer Anwender, der sich gerade bei deinem Compliance-Tool registriert hat, schaut auf eine englische Mail von einer Domain, die er nicht kennt, und überlegt, ob das Phishing ist. Die Verifizierungsrate fällt messbar.
Die Lösung steht in der AWS-Doku: Custom Email Sender Lambda. Cognito triggert eine Lambda-Funktion, die du selbst schreibst, und du übernimmst den Versand komplett. Eigene Domain, eigene Sprache, eigenes Template. Klingt geradlinig.
Es ist nicht geradlinig. Auf dem Weg zur produktiven Lösung warten drei Fallen, von denen die offizielle Doku zwei elegant verschweigt.
Falle 1: Der SMTP-Server, der nie senden durfte
Die erste Idee, wenn man E-Mail-Versand aus einer Lambda-Funktion bauen soll, ist meistens SMTP. Java hat JavaMail, Node hat Nodemailer, Python hat smtplib. Alles erprobt, alles dokumentiert, alles funktioniert lokal in zwei Stunden.
Mein Reflex damals: einen schlanken SMTP-Server in der Cloud aufsetzen, gegen den die Lambda-Funktion sendet. Postfix auf einer kleinen EC2-Instanz, alles hinter VPC, sauber per CDK provisioniert. Ich habe den ganzen Stack geschrieben. Security Group, Elastic IP, Route 53 Eintrag, Postfix-Konfiguration über User Data. Deployment lief durch. Lambda gewireed. Sign-up-Flow ausgelöst.
Nichts kam an.
Was ich nicht wusste: AWS blockiert ausgehenden Port 25 by default, sowohl auf EC2 als auch auf Lambda. Das ist eine plattformweite Anti-Spam-Maßnahme, die seit Jahren existiert. Die offizielle Begründung: Spam-Prävention und Reputation-Schutz für die AWS-IP-Bereiche. Man kann den Block über AWS Support aufheben lassen, aber das ist ein eigener Antragsweg mit Begründung, Domain-Nachweis und Wartezeit. Für ein produktives System mit ohnehin geringer SMTP-Last ist das ein unnötiger Umweg.
Die Erkenntnis: SMTP aus AWS ist eine Sackgasse, wenn man nicht aus geschäftlichen Gründen einen eigenen Mail-Server betreiben muss. Für Transaktionsmails ist der Weg ein anderer.
Falle 2: SES, aber nur über die API
Der vorgesehene Weg auf AWS heißt Simple Email Service (SES). Vollständig managed, mit Domain-Verifizierung, DKIM, Bounce-Tracking, sandbox-Limit zum Start, Production-Antrag wenn man bereit ist. Das ist solide. Mein nächster Schritt war also: Lambda umschreiben, gegen SES senden statt gegen Postfix.
Nun bietet SES zwei Schnittstellen. Erstens SMTP über SES, also ein klassischer SMTP-Endpoint, gegen den man mit JavaMail oder Nodemailer arbeiten kann. Zweitens die SES API, die mit der AWS-SDK direkt aufgerufen wird, ohne SMTP-Protokoll dazwischen.
Beim ersten Anlauf war SMTP-over-SES naheliegend, weil der bestehende Code von Falle 1 weiterverwendbar gewesen wäre. Nur: ich hoste in eu-central-2 (Zürich). Die Region ist seit 2022 verfügbar, und für Schweizer Datenschutzanforderungen ist sie der richtige Ort. Aber SES ist in eu-central-2 nur über die API verfügbar, nicht über SMTP-Endpoint. Der SMTP-Endpoint existiert in größeren Regionen wie eu-central-1 (Frankfurt) oder us-east-1, aber nicht in Zürich.
Damit war die Entscheidung wieder auf Code-Ebene zurückgeworfen: SES API direkt aus der Lambda. Was sich im Nachhinein als die bessere Wahl herausstellt. Die API-Variante ist schneller (keine SMTP-Handshake-Overhead), debuggbarer (saubere JSON-Responses statt SMTP-Status-Codes), und braucht keine zusätzlichen Credentials neben der IAM-Rolle der Lambda. Wer einmal SES per API integriert hat, will SMTP nicht mehr zurück.
Ein Detail, das man bei SES nicht überspringen sollte: jeder neue SES-Account startet im Sandbox-Modus. In der Sandbox kannst du nur an verifizierte Empfänger-Adressen senden, mit hartem Tageslimit. Für Produktion brauchst du den Antrag auf Production Access bei AWS Support, mit Beschreibung des Use Case, erwartetem Volumen, Bounce-Handling-Konzept. Der Antrag ist meistens innerhalb eines Tages durch, wenn das Konzept sauber ist. Nicht durch ist er, wenn man beim Bounce-Handling nichts sagen kann. Plant das ein, bevor das System live geht.
Mails kamen jetzt an. Mit eigener Domain, eigenem Branding, deutschsprachigem Template. Sah aus, als wäre das System fertig.
Falle 3: KMS-Verschlüsselung trifft Cold Start
Eine Woche später kamen die ersten Support-Anfragen. Nutzer berichteten, sie hätten zwei Verifizierungs-Codes erhalten, manche sogar drei. Beide Codes funktionierten, aber das wirkte chaotisch und unprofessionell. In Logs der Lambda war zu sehen: dieselbe Sign-up-Anfrage wurde mehrfach getriggert, in Abständen von wenigen Sekunden.
Hier kommt die Falle, die in fast keinem Tutorial vorkommt. Cognito übergibt der Custom Email Sender Lambda den Verifizierungs-Code nicht im Klartext. Der Code ist mit einem KMS-Schlüssel verschlüsselt, den du beim Anlegen des User Pools konfigurierst. Das macht Sinn aus Security-Sicht: der Code darf nicht in CloudWatch-Logs landen, falls jemand die Lambda-Logs einsieht.
Zum Entschlüsseln reicht aber nicht der einfache kms.Decrypt-Aufruf, weil Cognito mit einem Envelope-Encryption-Verfahren arbeitet. Du brauchst das AWS Encryption SDK, ein deutlich umfangreicheres Paket mit eigenen Materials Providers, Keyrings und Cryptographic Materials Manager. Auf der JVM bringt diese Library einen erheblichen Klassen-Footprint mit. Beim Cold Start einer Java Lambda muss all das geladen, initialisiert, verifiziert werden. Cold Starts mit Encryption SDK auf JVM bewegen sich erfahrungsgemäß im Bereich mehrerer Sekunden, je nach Memory-Setting und Region.
Cognito hat eine eigene Retry-Logik für Custom Email Sender Lambdas. Wenn die Lambda nicht innerhalb eines bestimmten Zeitfensters antwortet, ruft Cognito sie nochmal auf. Und nochmal. Bis es klappt oder das Limit erreicht ist. Bei einer langsamen JVM-Lambda mit Encryption SDK heißt das: erste Invocation läuft noch, zweite startet, beide schicken am Ende eine Mail, der Nutzer bekommt zwei Codes. Im schlimmsten Fall sogar drei, wenn die Lambda parallel skaliert.
Aus Cognito-Sicht ist die Retry-Logik korrekt. Cognito kann nicht wissen, ob die Lambda gerade nur lange braucht, oder ob sie wirklich abgestürzt ist und die Mail nie verschickt wird. Im Zweifel lieber ein Retry, damit der Nutzer nicht ohne Code dasteht. Nur ist die Logik unter der Annahme gebaut, dass die Lambda im üblichen Bereich antwortet, also unter einer Sekunde.
Die offensichtliche Lösung wäre, die Lambda warm zu halten (Provisioned Concurrency, scheduled Pings). Beides funktioniert, beides kostet zusätzlich, und beides löst nicht den initialen Cold Start nach einem Deployment, oder den Fall, dass die Lambda parallel skaliert und neue Instanzen kalt starten. Die robustere Lösung ist, die Lambda so zu bauen, dass sie gar keinen relevanten Cold Start hat.
Mein Stack dafür: Quarkus Native. Java-Code, mit GraalVM zu einem Native Binary kompiliert. Cold Start im niedrigen dreistelligen Millisekunden-Bereich, deutlich unterhalb des Cognito-Retry-Fensters. Das gleiche Prinzip gilt für andere Native-Frameworks (Micronaut, Spring Native), oder alternativ Node.js und Python, die ohne JVM-Warm-up auskommen.
Eine Detailfeinheit verdient Erwähnung: Native-Compilation und das AWS Encryption SDK vertragen sich nicht out-of-the-box. Die Kombination braucht zusätzliche Konfiguration für die Reflection und einige Klassen-Initialisierungen, die man in Tutorials selten dokumentiert findet. Wer den Weg geht, sollte mit etwas Trial-and-Error rechnen, vor allem bei der ersten Native Image Build. Wenn es dann läuft, läuft es schnell und stabil.
Idempotenz als zweite Verteidigungslinie
Selbst mit einer schnellen Lambda kann der Retry-Fall in Spitzenlast-Momenten auftreten. Eine zweite Verteidigungslinie ist deshalb sinnvoll: die Mail-Versand-Logik idempotent zu bauen. Konkret heißt das, anhand von Cognito-Trigger-Source und der User-Identity zu prüfen, ob in den letzten Sekunden bereits eine Mail mit demselben Code rausging. Eine kleine DynamoDB-Tabelle mit TTL reicht dafür, oder eine Markierung in einem bestehenden User-Record. Aufwand gering, Wirkung hoch: doppelte Mails sind in der Architektur ausgeschlossen, nicht nur unwahrscheinlich.
In der Praxis ist die Kombination aus schneller Lambda und Idempotenz-Check der robuste Stand. Nur eine schnelle Lambda allein verlässt sich darauf, dass Cognito unter keinen Umständen retried, was nicht garantiert ist. Nur Idempotenz ohne schnelle Lambda erzeugt zwar keine doppelten Mails mehr, aber jede zweite Invocation läuft trotzdem voll durch und kostet Lambda-Zeit. Beides zusammen ist sauber.
Architektur-Übersicht
Die Bausteine, die am Ende produktiv laufen:
Der Flow im Detail. Der Nutzer registriert sich über deine Anwendung gegen Cognito (1). Cognito generiert einen Verifizierungs-Code, verschlüsselt ihn mit dem konfigurierten KMS-Key, und triggert die Custom Email Sender Lambda mit dem verschlüsselten Payload (2). Die Lambda entschlüsselt den Code über das AWS Encryption SDK (3), baut die Mail mit dem deutschen Template, und ruft SES.SendEmail auf (4). SES stellt die Mail über die verifizierte Domain zu (5).
Vier Komponenten, drei davon sind AWS-managed, eine ist eigener Code. Genau dieser eine Code-Anteil entscheidet, ob das System produktiv tragfähig ist.
Trade-offs: welche Runtime für die Lambda
Die Wahl der Runtime ist die einzige nicht-triviale Architektur-Entscheidung im ganzen Setup. Eine grobe Übersicht:
| Option | Cold Start | Encryption SDK | Empfehlung |
|---|---|---|---|
| Java JVM | mehrere s | native | vermeiden |
| Java Native | unter 500ms | Reflection-Config | empfohlen |
| Node.js | unter 500ms | JS-SDK verfügbar | empfohlen |
| Python | unter 500ms | pip Paket | empfohlen |
Die Tabelle ist eine Faustregel, keine Messreihe. Tatsächliche Cold-Start-Zeiten hängen von Memory-Setting, Region, Paketgröße und VPC-Konfiguration ab. Wer eine Java-Codebasis und ein Java-Team hat, fährt mit Native gut. Wer von grüner Wiese startet, kann sich Node.js oder Python sparen die Reflection-Diskussion.
Eine echte Messreihe steht auf meinem Plan. Ich arbeite an einem Cold-Start-Benchmark, der Quarkus Native, Java JVM und Node.js unter denselben Bedingungen vergleicht: gleiches Memory-Setting, gleiche Region (eu-central-2), gleicher Workload mit Encryption SDK. Sobald die Zahlen stehen, gibt es einen eigenen Beitrag dazu, mit reproduzierbarem Setup und Repository.
Wann braucht man das nicht?
Wenn dein Produkt englischsprachig, US-zentriert und ohne Branding-Anspruch ist, reicht der Cognito-Default. Wenn du nur intern nutzt und keine externen Empfänger hast, ist die Frage egal. Die Custom Email Sender Lambda ist die Antwort auf konkretes Marketing- und Compliance-Bedürfnis: lokalisiert, gebrandet, datenschutzkonform absendend von der eigenen Domain.
Das größere Muster
Die Geschichte hat eine Pointe, die über Cognito hinausgeht. Drei Mal in diesem Projekt war mein erster Reflex, etwas selbst zu bauen. Einen SMTP-Server. Einen SMTP-Client gegen SES. Eine schnelle JVM-Lambda mit aufwendigem Warm-up. Drei Mal war die bessere Lösung, einen AWS-managed-Service zu nutzen oder ein etabliertes Framework korrekt einzusetzen, statt eigenen Code anzuhäufen.
Das ist kein Cognito-spezifisches Phänomen. Es ist das wiederkehrende Muster bei Cloud-Architektur. AWS bietet mehrere hundert Services, und die meisten existieren, weil ein konkretes Problem in einer Größenordnung auftritt, die DIY unwirtschaftlich macht. Auth, Mail-Versand, Verschlüsselung, Queue-Verarbeitung. Wer das selbst baut, baut Software, die jemand anders schon gebaut hat, und übernimmt die Wartung dafür.
In einem früheren Beitrag habe ich denselben Punkt für Authentifizierung gemacht: Logins baut man nicht selbst. Cognito plus Authorization@Edge plus API Gateway lösen das Problem in einem Bruchteil der Zeit, mit deutlich besserer Sicherheit. Hier ist die Variante davon für E-Mail-Versand: Cognito plus SES plus KMS, dazu eine schlanke Lambda als Klebstoff. Der eigene Code-Anteil schrumpft auf das, was wirklich produktspezifisch ist. Die Templates, die deutsche Übersetzung, die Branding-Entscheidungen.
Die Faustregel, die sich daraus ergibt: jeder Service, den AWS anbietet, ist eine Frage. Brauche ich diese Funktion? Wenn ja, fast immer ist die Antwort, den Service zu nutzen, nicht ihn nachzubauen. Die Ausnahmen lassen sich an einer Hand abzählen, und sie sind meistens regulatorischer Natur, nicht technischer.