How I Built A Signup Flow With Email Code Verification
Uriel Bitton
AWS Cloud Engineer | The DynamoDB guy | AWS Certified & AWS Community Builder | I help you build scalable DynamoDB databases ????
I recently had to build a signup flow for one of my client’s mobile apps.
My client needed a simple system that would perform the following process:
The microservice was made up entirely of AWS services.
Overview
Here’s a general overview of the flow I created to satisfy this feature:
Let’s go through the implementation of each of these processes in detail.
1. Cognito User Pool
In the AWS console, I created a new user pool.
I chose “Traditional web application” and entered an application name.
Under Configure options I chose email as Options for sign-in identifiers.
Once I clicked on Create user directory, I then have all the code necessary to implement the user auth for my app.
Let’s now look at the Lambda function I wrote to generate the verification code.
Let’s focus on the verification code flow in this article, but if you want to learn more details on how to setup Cognito, I wrote a full article here.
2. Lambda function to generate verification code
Here is the general flow:
I created an endpoint using AWS API Gateway with a route for “user-signups”.
Users can invoke that endpoint which triggers a Lambda function.
The Lambda function accepts an email as a path parameter (event.pathParameter in Lambda).
Next, I used the crypto Node JS library to generate a 6 digit code.
I then write an item to DynamoDB containing the following data:
The timestamp I add is an item defined as a TTL so that the item gets automatically deleted by DynamoDB.
Since DynamoDB takes up to 48 hours to delete TTL items, in the second Lambda function that verifies the code (below), I check the item’s ttl timestamp to check if more than 15 minutes have elapsed since the item’s creation and return a verification error if it has expired.
I explain more about TTLs in DynamoDB in this article if you’re interested.
Once the item has been successfully written to the database, I use SES to send an email containing the verification code to the user’s email address.
Here’s the Lambda function code:
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { randomInt } from 'crypto';
import { marshall } from '@aws-sdk/util-dynamodb'
const ses = new SESClient({});
const dynamoDb = new DynamoDBClient({});
const adminEmail = process.env.adminEmail;
const themeColor = "#316afd"
export const handler = async (event) => {
const { email } = event.queryStringParameters;
if (!email) {
throw new Error("Email address is required.")
}
try {
const code = generateCode();
const params = {
TableName: 'my-app',
Item: marshall({
pk: `authcode#${email}`,
sk: email,
email,
code,
ttl: Math.floor(Date.now() / 1000) + 15 * 60,
timeCreated: Math.floor(Date.now() / 1000)
})
};
await dynamoDb.send(new PutItemCommand(params));
const emailParams = {
Source: adminEmail,
Destination: {
ToAddresses: [email],
},
Message: {
Subject: {
Data: 'Your Verification Code',
},
Body: {
Html: {
Data: `<html>
<body>
<p>
<strong>Your verification code is:</strong>
<br><br>
<span style="font-size: 24px; color: blue;">${code}</span>
</p>
<p>
<em>This code will expire in 15 minutes.</em>
</p>
</body>
</html>`,
},
},
},
};
await ses.send(new SendEmailCommand(emailParams));
return {
statusCode: 200,
body: JSON.stringify({ message: 'Code sent successfully' }),
};
} catch (error) {
console.error(error);
return {
statusCode: 500,
body: JSON.stringify({ message: 'Internal server error', error }),
};
}
};
function generateCode() {
return randomInt(100000, 999999).toString();
}
3. Lambda function to verify code
When the user opens their email, they copy the verification code and can submit it back on the app.
When they do, here’s the process I created to verify that code:
Here’s the full code:
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall } from '@aws-sdk/util-dynamodb';
const dynamoDb = new DynamoDBClient({});
export const handler = async (event) => {
const { email, code, userID } = JSON.parse(event.body);
if (!email || !code || !userID) {
return {
statusCode: 400,
body: JSON.stringify({ message: 'Required parameters are missing.' }),
};
}
try {
const params = {
TableName: 'my-app',
Key: marshall({
pk: `authcode#${email}`,
sk: email
}),
};
const data = await dynamoDb.send(new GetItemCommand(params));
if (!data.Item) {
return {
statusCode: 404,
body: JSON.stringify({ message: 'No record found for this email.' }),
};
}
const storedCode = data.Item.code.S;
const currentTime = Math.floor(Date.now() / 1000);
const storedEmail = data.Item.email.S;
const timeCreated = data.Item.timeCreated.N;
const timeHasExpired = currentTime > +timeCreated + 15 * 60;
if (storedCode !== code) {
return {
statusCode: 400,
body: JSON.stringify({ message: 'Incorrect verification code.' }),
};
}
if (timeHasExpired) {
return {
statusCode: 400,
body: JSON.stringify({ message: 'Verification code has expired.' }),
};
}
if (email !== storedEmail) {
return {
statusCode: 400,
body: JSON.stringify({ message: 'Email addresses do not match.' }),
};
}
return {
statusCode: 200,
body: JSON.stringify({ message: 'User verified successfully!' }),
};
} catch (error) {
console.error(error);
return {
statusCode: 500,
body: JSON.stringify({ message: 'Internal server error' }),
};
}
};
Here’s an quick demo of invoking the function:
I get an email:
And the item appears in my DynamoDB table:
(Of course I deleted this item before publishing the article ??).
One note on using a TTL and a timeCreated timestamp: I do this because these two attributes serve two different purposes.
The TTL is used to tell DynamoDB to automatically delete the item after it is expired (since I do not need it).
Since the TTL deletion isn’t instantaneous, I also need to verify the timestamp at which the item with the code was created in order to invalidate it if more than 15 minutes have elapsed since its creation.
Summary
Creating a signup verification flow using AWS services is simple and straightforward.
With a little understanding of DynamoDB TTLs and using Amazon SES to send emails, you can create this flow while keeping best practices and optimizing your database’s storage.
?? My name is Uriel Bitton and I hope you learned something in this edition of The Serverless Spotlight
?? You can share the article with your network to help others learn as well.
?? If you want to learn how to save money in the cloud you can subscribe to my brand new newsletter The Cloud Economist.
?? I hope to see you in next week's edition!