
Building Custom Email Workflows for AWS Cognito with Lambda
Stewart Moreland
AWS Cognito makes it easy to handle sign‑up, password resets, multi‑factor auth, and more—but the out‑of‑the‑box messages are pretty plain. With a single Lambda "CustomMessage" trigger you can own every email and SMS your users see, rebranding them to match your product's look & feel and adding rich HTML, buttons, inlined styles, or even multi‑language support.
What We'll Cover
- Explain Cognito's CustomMessage trigger
- Scaffold a Lambda project (TypeScript or JavaScript)
- Walk through handler code for all the common email types
- Deploy and wire up the trigger to your User Pool
- Test and iterate on your templates
- Share best‑practices for maintainable, localized messaging
1. What Is the "CustomMessage" Trigger?
When you enable the CustomMessage trigger on your Cognito User Pool, every time Cognito needs to send an email or SMS—for sign‑up verification, forgotten‑password, admin‑created users, code resends, attribute updates—you can intercept it in a Lambda and override:
- emailSubject
- emailMessage (HTML or plain text)
- smsMessage (text‑only fallback)
Available Trigger Sources
Based on the event.triggerSource you can craft completely different templates for:
CustomMessage_SignUpCustomMessage_ForgotPasswordCustomMessage_AdminCreateUserCustomMessage_ResendCodeCustomMessage_UpdateUserAttribute
Cognito then uses what you set on event.response instead of its defaults.
2. Scaffolding the Lambda Project
Prerequisites
Before You Begin
Make sure you have:
- AWS CLI or Console access with permissions to create Lambda & Cognito triggers
- Node.js ≥ 14 (or your preferred runtime)
- A Cognito User Pool already in place
Project Setup
mkdir cognito-custom-messagescd cognito-custom-messagesnpm init -ynpm install aws-lambdanpm install --save-dev typescript @types/aws-lambda
Create a basic tsconfig.json:
{"compilerOptions": {"target": "es2020","module": "commonjs","strict": true,"outDir": "dist"},"include": ["src/**/*"]}
Your folder tree should look like this:
cognito-custom-messages/├─ src/│ └─ handler.ts├─ tsconfig.json└─ package.json
3. Writing the Handler
In src/handler.ts, import the Cognito event type and craft your HTML templates. Here's a generic, brand‑agnostic example:
import { CustomMessageTriggerEvent, Context, Callback } from 'aws-lambda';// You can also load these from environment variables or a config fileconst BRAND = {name: 'YourAppName',logoUrl: 'https://yourcdn.com/logo.png',accentColor: '#1E88E5',baseColor: '#1565C0',supportEmail: 'support@yourapp.com',};export const handler = async (event: CustomMessageTriggerEvent,context: Context): Promise<CustomMessageTriggerEvent> => {console.log('Incoming CustomMessage event:', JSON.stringify(event, null, 2));// Header / footer can be extracted to functions or external filesconst header = `<div style="font-family: sans-serif; max-width:600px; margin:auto;"><img src="${BRAND.logoUrl}" alt="${BRAND.name}" style="width:100px; margin-bottom:20px;" />`;const footer = `<p style="font-size:12px; color:#888;">© ${new Date().getFullYear()} ${BRAND.name}. All rights reserved.</p><p style="font-size:12px; color:#888;">Need help? <a href="mailto:${BRAND.supportEmail}" style="color:${BRAND.accentColor}; text-decoration:none;">${BRAND.supportEmail}</a></p></div>`;// Helper to wrap a code in a styled boxconst renderCode = (code: string) => `<div style="background:#f4f4f4; padding:16px; border-radius:8px; text-align:center; margin:20px 0;"><code style="font-size:1.5em; letter-spacing:4px;">${code}</code></div>`;const { triggerSource, request, response } = event;const code = request.codeParameter;const user = request.usernameParameter ?? event.userName;switch (triggerSource) {case 'CustomMessage_SignUp':response.emailSubject = `Welcome to ${BRAND.name}! Confirm your address`;response.emailMessage = `${header}<h2 style="color:${BRAND.baseColor};">Hello, and thanks for joining ${BRAND.name}!</h2><p>Please confirm your email by entering the code below:</p>${renderCode(code!)}<p>If you didn't sign up, simply ignore this email.</p>${footer}`;response.smsMessage = `Your ${BRAND.name} signup code is: ${code}`;break;case 'CustomMessage_ForgotPassword':response.emailSubject = `${BRAND.name} Password Reset`;response.emailMessage = `${header}<h2 style="color:${BRAND.baseColor};">Password Reset Requested</h2><p>Use the code below to reset your password:</p>${renderCode(code!)}<p>If you didn't request this, please ignore.</p>${footer}`;response.smsMessage = `${BRAND.name} reset code: ${code}`;break;case 'CustomMessage_AdminCreateUser':response.emailSubject = `${BRAND.name} — Your new account`;response.emailMessage = `${header}<h2 style="color:${BRAND.baseColor};">Your account is ready!</h2><p>Username: <strong>${user}</strong></p><p>Temporary password:</p>${renderCode(code!)}<p>Please sign in and set a new password.</p>${footer}`;response.smsMessage = `Welcome to ${BRAND.name}! Username: ${user}, Temp password: ${code}`;break;case 'CustomMessage_ResendCode':response.emailSubject = `${BRAND.name} Verification Code`;response.emailMessage = `${header}<h2 style="color:${BRAND.baseColor};">Here's your verification code</h2><p>Your new verification code:</p>${renderCode(code!)}<p>This code will expire in 24 hours.</p>${footer}`;response.smsMessage = `${BRAND.name} verification code: ${code}`;break;case 'CustomMessage_UpdateUserAttribute':response.emailSubject = `${BRAND.name} Verify Your Update`;response.emailMessage = `${header}<h2 style="color:${BRAND.baseColor};">Verify Your Account Update</h2><p>Please verify this change with the code below:</p>${renderCode(code!)}<p>If you didn't make this change, contact support immediately.</p>${footer}`;response.smsMessage = `${BRAND.name} verification code: ${code}`;break;default:console.log('Unknown trigger source:', triggerSource);}return event;};
Keep your HTML snippets modular—load headers, footers, and translations from separate files or S3 if you need localization.
4. Packaging & Deployment
service: cognito-custom-messagesprovider:name: awsruntime: nodejs18.xregion: us-east-1environment:BRAND_NAME: ${env:BRAND_NAME}BRAND_LOGO: ${env:BRAND_LOGO}functions:customMessage:handler: dist/handler.handlermemorySize: 128timeout: 10events:- cognitoUserPool:pool: YourUserPooltrigger: CustomMessage
Then deploy:
npx sls deploy
5. Hooking It Up in Cognito
Setup Steps
- In the AWS Console, go to Cognito → User Pools → YourPool → Triggers
- Under CustomMessage, select the Lambda you just deployed
- Save and test by creating a new user or invoking the trigger via the CLI
Test using AWS CLI:
aws cognito-idp admin-create-user \--user-pool-id YOUR_POOL_ID \--username testuser \--message-action SUPPRESS
Check CloudWatch logs for your Lambda's console output to verify everything is working correctly.
6. Testing & Iteration
Testing Strategies
- Automated tests: Simulate event payloads locally or in CI to verify your HTML renders without errors
- Preview links: In your email templates, include a "View in browser" link back to an S3‑hosted HTML preview
- Fallback SMS: Always set
response.smsMessage, since not all users accept HTML email
const { handler } = require('./src/handler');describe('Cognito CustomMessage Lambda', () => {test('should generate signup email with correct subject and code', async () => {const mockEvent = {triggerSource: 'CustomMessage_SignUp',request: {codeParameter: '123456',usernameParameter: 'testuser@example.com'},response: {}};const result = await handler(mockEvent);expect(result.response.emailSubject).toContain('Welcome to YourAppName');expect(result.response.emailMessage).toContain('123456');expect(result.response.smsMessage).toContain('123456');});test('should handle forgot password trigger', async () => {const mockEvent = {triggerSource: 'CustomMessage_ForgotPassword',request: { codeParameter: '789012' },response: {}};const result = await handler(mockEvent);expect(result.response.emailSubject).toContain('Password Reset');expect(result.response.emailMessage).toContain('789012');});});
7. Best Practices
Key Recommendations
- Environment variables (or secrets): store branding, support contacts, and links outside of code
- Localization: extract all copy into JSON/YAML files and select by
event.request.userAttributes.locale - Version your templates: consider a "templateVersion" user attribute so you can A/B test designs over time
- Error handling: wrap your switch in try/catch and log details so you don't break the Cognito flow
- Accessibility: use semantic HTML, alt text on images, and sufficient color contrast
Advanced Template Management
For larger applications, consider implementing a template management system:
interface TemplateConfig {locale: string;version: string;brand: {name: string;logoUrl: string;colors: {primary: string;accent: string;};};}class TemplateManager {private templates: Map<string, any> = new Map();constructor(private config: TemplateConfig) {}getTemplate(triggerSource: string, locale = 'en'): string {const templateKey = `${triggerSource}_${locale}`;if (!this.templates.has(templateKey)) {// Load template from S3, database, or local filesthis.loadTemplate(templateKey);}return this.templates.get(templateKey);}private loadTemplate(templateKey: string): void {// Implementation for loading templates// Could load from S3, DynamoDB, or local files}renderCode(code: string): string {return `<div style="background: #f8f9fa;border: 2px solid ${this.config.brand.colors.primary};padding: 20px;border-radius: 12px;text-align: center;margin: 24px 0;box-shadow: 0 4px 6px rgba(0,0,0,0.1);"><code style="font-size: 2em;font-weight: bold;letter-spacing: 8px;color: ${this.config.brand.colors.primary};font-family: 'Courier New', monospace;">${code}</code></div>`;}}export { TemplateManager, TemplateConfig };
import { CustomMessageTriggerEvent } from 'aws-lambda';import { TemplateManager } from './templates/templateManager';const templateManager = new TemplateManager({locale: 'en',version: '1.0',brand: {name: process.env.BRAND_NAME || 'YourApp',logoUrl: process.env.BRAND_LOGO || '',colors: {primary: '#1E88E5',accent: '#1565C0'}}});export const handler = async (event: CustomMessageTriggerEvent): Promise<CustomMessageTriggerEvent> => {try {const { triggerSource, request, response } = event;const userLocale = request.userAttributes?.locale || 'en';// Get localized templateconst template = templateManager.getTemplate(triggerSource, userLocale);// Process template with user dataconst processedTemplate = template.replace('{{code}}', templateManager.renderCode(request.codeParameter)).replace('{{username}}', request.usernameParameter || event.userName);response.emailMessage = processedTemplate;response.emailSubject = getLocalizedSubject(triggerSource, userLocale);response.smsMessage = getLocalizedSMS(triggerSource, request.codeParameter, userLocale);return event;} catch (error) {console.error('Template processing error:', error);// Fallback to default Cognito messagesreturn event;}};function getLocalizedSubject(triggerSource: string, locale: string): string {// Implementation for localized subjectsreturn 'Default Subject';}function getLocalizedSMS(triggerSource: string, code: string, locale: string): string {// Implementation for localized SMSreturn `Your code: ${code}`;}
Security & Performance Tips
- Never log sensitive user data in Lambda logs
- Use environment variables for all configuration
- Implement rate limiting for template rendering
- Validate all template inputs to prevent injection attacks
- Cache compiled templates in memory between invocations
- Use connection pooling for external template sources
- Minimize Lambda cold starts with provisioned concurrency
- Consider using Lambda@Edge for global template distribution
Conclusion
With just one Lambda and a handful of triggerSource cases, you can transform your Cognito emails and SMS into on‑brand, accessible, and engaging messages. Whether you're running a growth campaign, supporting multiple languages, or simply want a nicer "forgot password" flow, this pattern scales to any use case.
The advanced template management system shown above enables you to:
- Support multiple languages and locales
- A/B test different email designs
- Maintain brand consistency across all communications
- Handle complex template logic without cluttering your Lambda
Next Steps
- Review the AWS Cognito Documentation for more details
- Explore our other AWS authentication guides
- Consider implementing the advanced template management system for production applications
- Set up monitoring and analytics for your custom messaging workflows
Happy coding—and happy customizing!