Building Custom Email Workflows for AWS Cognito with Lambda

Building Custom Email Workflows for AWS Cognito with Lambda

S

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.

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)

Cognito then uses what you set on event.response instead of its defaults.

2. Scaffolding the Lambda Project

Prerequisites

Project Setup

NPM Setup
mkdir cognito-custom-messages
cd cognito-custom-messages
npm init -y
npm install aws-lambda
npm install --save-dev typescript @types/aws-lambda

Create a basic tsconfig.json:

tsconfig.json
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"strict": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}

Your folder tree should look like this:

Project Structure
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:

src/handler.ts
import { CustomMessageTriggerEvent, Context, Callback } from 'aws-lambda';
// You can also load these from environment variables or a config file
const 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 files
const 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 box
const 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;
};
💡 Template Organization

Keep your HTML snippets modular—load headers, footers, and translations from separate files or S3 if you need localization.

4. Packaging & Deployment

serverless.yml
service: cognito-custom-messages
provider:
name: aws
runtime: nodejs18.x
region: us-east-1
environment:
BRAND_NAME: ${env:BRAND_NAME}
BRAND_LOGO: ${env:BRAND_LOGO}
functions:
customMessage:
handler: dist/handler.handler
memorySize: 128
timeout: 10
events:
- cognitoUserPool:
pool: YourUserPool
trigger: CustomMessage

Then deploy:

Deploy with Serverless
npx sls deploy

5. Hooking It Up in Cognito

Test using AWS CLI:

Test the Trigger
aws cognito-idp admin-create-user \
--user-pool-id YOUR_POOL_ID \
--username testuser \
--message-action SUPPRESS
💡 Debugging

Check CloudWatch logs for your Lambda's console output to verify everything is working correctly.

6. Testing & Iteration

📊 Email Performance Metrics
+2%
99.2%%
Delivery Rate
+15%
68%%
Open Rate
-25%
120msms
Lambda Cold Start
-10%
45msms
Processing Time

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
tests/handler.test.js
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

Advanced Template Management

For larger applications, consider implementing a template management system:

src/templates/templateManager.ts
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 files
this.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 };
src/enhanced-handler.ts
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 template
const template = templateManager.getTemplate(triggerSource, userLocale);
// Process template with user data
const 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 messages
return event;
}
};
function getLocalizedSubject(triggerSource: string, locale: string): string {
// Implementation for localized subjects
return 'Default Subject';
}
function getLocalizedSMS(triggerSource: string, code: string, locale: string): string {
// Implementation for localized SMS
return `Your code: ${code}`;
}

Security & Performance Tips

💡 Security Considerations
  • 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
💡 Performance Optimization
  • 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

Happy coding—and happy customizing!