In this tutorial, we will walk through how to build a serverless OTP (One-Time Password) system using AWS services, and deploy the entire infrastructure using Terraform (Infrastructure as Code). This system sends OTPs to users via SMS using Amazon SNS and verifies them using DynamoDB.
Architecture Overview
We will use the following AWS services:
- API Gateway: To expose RESTful endpoints
/send-otpand/verify-otp. - AWS Lambda: To handle OTP generation and verification logic.
- Amazon DynamoDB: To store OTPs with expiration timestamps (TTL).
- Amazon SNS: To send OTPs as SMS to users.
- Terraform: To provision all infrastructure.
Prerequisites
- AWS Account
- Terraform installed (>= 1.0.0)
- AWS CLI configured
- Basic knowledge of AWS services and Python
Project Structure
Your project should be organized as follows:
.
├── main.tf
├── variables.tf
├── outputs.tf
├── lambda/
│ ├── send_otp.py
│ └── verify_otp.py
└── lambda.zip (Generated from lambda/ directory)
Step-by-Step Implementation
Step 1: Write Lambda Functions
Create the Python files for your Lambda functions inside the lambda directory.
lambda/send_otp.py
import json
import boto3
import random
import time
import os
dynamodb = boto3.resource('dynamodb')
sns = boto3.client('sns')
# Ensure OTP_TABLE environment variable is set in Lambda configuration
table_name = os.environ.get('OTP_TABLE')
if not table_name:
raise ValueError("Missing OTP_TABLE environment variable")
table = dynamodb.Table(table_name)
def lambda_handler(event, context):
try:
body = json.loads(event.get('body', '{}'))
except json.JSONDecodeError:
return {
'statusCode': 400,
'body': json.dumps({'error': 'Invalid JSON in request body'})
}
user_id = body.get('userId') # Phone number for SMS
if not user_id:
return {
'statusCode': 400,
'body': json.dumps({'error': 'userId (phone number) is required'})
}
otp = str(random.randint(100000, 999999))
# TTL for 5 minutes (300 seconds)
# DynamoDB TTL attribute must be a Unix epoch timestamp in seconds
ttl_timestamp = int(time.time()) + 300
try:
table.put_item(Item={
'userId': user_id,
'otp': otp,
'expirationTime': ttl_timestamp # Ensure this matches TTL attribute in DynamoDB
})
sns.publish(
PhoneNumber=user_id,
Message=f"Your OTP is: {otp}"
)
except Exception as e:
print(f"Error processing OTP: {e}") # Log error for debugging
return {
'statusCode': 500,
'body': json.dumps({'error': 'Failed to send OTP', 'details': str(e)})
}
return {
'statusCode': 200,
'body': json.dumps({'message': 'OTP sent successfully'})
}
lambda/verify_otp.py
import json
import boto3
import time
import os
dynamodb = boto3.resource('dynamodb')
# Ensure OTP_TABLE environment variable is set in Lambda configuration
table_name = os.environ.get('OTP_TABLE')
if not table_name:
raise ValueError("Missing OTP_TABLE environment variable")
table = dynamodb.Table(table_name)
def lambda_handler(event, context):
try:
body = json.loads(event.get('body', '{}'))
except json.JSONDecodeError:
return {
'statusCode': 400,
'body': json.dumps({'verified': False, 'error': 'Invalid JSON in request body'})
}
user_id = body.get('userId')
input_otp = body.get('otp')
if not user_id or not input_otp:
return {
'statusCode': 400,
'body': json.dumps({'verified': False, 'error': 'userId and otp are required'})
}
try:
response = table.get_item(Key={'userId': user_id})
except Exception as e:
print(f"Error fetching OTP from DynamoDB: {e}")
return {
'statusCode': 500,
'body': json.dumps({'verified': False, 'error': 'Failed to retrieve OTP details', 'details': str(e)})
}
item = response.get('Item')
if not item:
return {
'statusCode': 404,
'body': json.dumps({'verified': False, 'error': 'OTP not found or already used'})
}
# Check if OTP has expired - DynamoDB TTL should handle deletion,
# but this is a good safeguard if item hasn't been reaped yet.
# 'expirationTime' is expected to be a Unix epoch timestamp in seconds.
if int(time.time()) > item.get('expirationTime', 0):
# Optionally, delete the expired item here if not relying solely on TTL
# table.delete_item(Key={'userId': user_id})
return {
'statusCode': 410, # HTTP 410 Gone
'body': json.dumps({'verified': False, 'error': 'OTP expired'})
}
if item.get('otp') == input_otp:
# Optionally, delete the OTP item after successful verification
# to prevent reuse, if not relying on TTL for cleanup.
# table.delete_item(Key={'userId': user_id})
return {
'statusCode': 200,
'body': json.dumps({'verified': True, 'message': 'OTP verified successfully'})
}
return {
'statusCode': 401, # HTTP 401 Unauthorized
'body': json.dumps({'verified': False, 'error': 'Invalid OTP'})
}
Step 2: Terraform Configuration
Create the following Terraform files in the root of your project.
main.tf
This file sets up all the AWS resources including the required IAM roles and permissions.
provider "aws" {
region = var.aws_region
}
# --- DynamoDB Table for OTPs ---
resource "aws_dynamodb_table" "otp_table" {
name = "OTPsTable-${random_id.suffix.hex}" # Unique table name
billing_mode = "PAY_PER_REQUEST"
hash_key = "userId" # Primary key
attribute {
name = "userId" # Phone number or user identifier
type = "S" # String
}
# TTL (Time To Live) configuration to automatically delete expired OTPs
ttl {
attribute_name = "expirationTime" # Must match the attribute name in Lambda
enabled = true
}
tags = {
Name = "OTPStorageTable"
Environment = "Production" # Or your environment
}
}
# Generate a random suffix for unique resource naming
resource "random_id" "suffix" {
byte_length = 4
}
# --- IAM Role and Policies for Lambda ---
resource "aws_iam_role" "lambda_exec_role" {
name = "otp_lambda_exec_role-${random_id.suffix.hex}"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [{
Action = "sts:AssumeRole",
Effect = "Allow",
Principal = { Service = "lambda.amazonaws.com" }
}]
})
tags = {
Name = "OTP Lambda Execution Role"
}
}
# Basic Lambda execution policy (CloudWatch Logs)
resource "aws_iam_role_policy_attachment" "lambda_basic_exec_policy" {
role = aws_iam_role.lambda_exec_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# Custom IAM policy for DynamoDB and SNS access
resource "aws_iam_policy" "lambda_custom_permissions_policy" {
name = "otp_lambda_permissions_policy-${random_id.suffix.hex}"
description = "Policy for Lambda to access DynamoDB OTP table and publish to SNS"
policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect = "Allow",
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem", # If you implement OTP deletion after verification
"dynamodb:UpdateItem" # If you need to update items
],
Resource = aws_dynamodb_table.otp_table.arn
},
{
Effect = "Allow",
Action = ["sns:Publish"],
Resource = "*" # Restrict this to specific SNS topics in production if possible
}
]
})
}
resource "aws_iam_role_policy_attachment" "lambda_custom_permissions_attachment" {
role = aws_iam_role.lambda_exec_role.name
policy_arn = aws_iam_policy.lambda_custom_permissions_policy.arn
}
# --- Lambda Functions ---
# Package the lambda functions
data "archive_file" "lambda_zip" {
type = "zip"
source_dir = "${path.module}/lambda/"
output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "send_otp_lambda" {
filename = data.archive_file.lambda_zip.output_path
function_name = "sendOTPFunction-${random_id.suffix.hex}"
role = aws_iam_role.lambda_exec_role.arn
handler = "send_otp.lambda_handler" # Corresponds to filename.function_name
runtime = "python3.12"
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
environment {
variables = {
OTP_TABLE = aws_dynamodb_table.otp_table.name
}
}
tags = {
Name = "SendOTP Lambda"
}
}
resource "aws_lambda_function" "verify_otp_lambda" {
filename = data.archive_file.lambda_zip.output_path
function_name = "verifyOTPFunction-${random_id.suffix.hex}"
role = aws_iam_role.lambda_exec_role.arn
handler = "verify_otp.lambda_handler" # Corresponds to filename.function_name
runtime = "python3.12"
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
environment {
variables = {
OTP_TABLE = aws_dynamodb_table.otp_table.name
}
}
tags = {
Name = "VerifyOTP Lambda"
}
}
# --- API Gateway (HTTP API v2) ---
resource "aws_apigatewayv2_api" "otp_api" {
name = "OTPApi-${random_id.suffix.hex}"
protocol_type = "HTTP"
description = "API Gateway for Serverless OTP Service"
tags = {
Name = "OTP Service API"
}
}
resource "aws_apigatewayv2_stage" "default_stage" {
api_id = aws_apigatewayv2_api.otp_api.id
name = "$default" # Default stage
auto_deploy = true # Automatically deploy changes
tags = {
Name = "OTP API Default Stage"
}
}
# API Gateway Integrations with Lambda
resource "aws_apigatewayv2_integration" "send_otp_integration" {
api_id = aws_apigatewayv2_api.otp_api.id
integration_type = "AWS_PROXY" # For Lambda proxy integration
integration_uri = aws_lambda_function.send_otp_lambda.invoke_arn
payload_format_version = "2.0" # For HTTP APIs
}
resource "aws_apigatewayv2_integration" "verify_otp_integration" {
api_id = aws_apigatewayv2_api.otp_api.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.verify_otp_lambda.invoke_arn
payload_format_version = "2.0"
}
# API Gateway Routes
resource "aws_apigatewayv2_route" "send_otp_route" {
api_id = aws_apigatewayv2_api.otp_api.id
route_key = "POST /send-otp" # Method and path
target = "integrations/${aws_apigatewayv2_integration.send_otp_integration.id}"
}
resource "aws_apigatewayv2_route" "verify_otp_route" {
api_id = aws_apigatewayv2_api.otp_api.id
route_key = "POST /verify-otp"
target = "integrations/${aws_apigatewayv2_integration.verify_otp_integration.id}"
}
# Permissions for API Gateway to invoke Lambda functions
resource "aws_lambda_permission" "api_gw_send_otp_permission" {
statement_id = "AllowAPIGatewayInvokeSendOTP"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.send_otp_lambda.function_name
principal = "apigateway.amazonaws.com"
# Source ARN restricts which API Gateway can invoke this Lambda
source_arn = "${aws_apigatewayv2_api.otp_api.execution_arn}/*/*"
}
resource "aws_lambda_permission" "api_gw_verify_otp_permission" {
statement_id = "AllowAPIGatewayInvokeVerifyOTP"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.verify_otp_lambda.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.otp_api.execution_arn}/*/*"
}
variables.tf
variable "aws_region" {
description = "The AWS region to deploy resources into."
type = string
default = "us-east-1"
}
outputs.tf
output "api_endpoint" {
description = "The base URL endpoint for the OTP API."
value = aws_apigatewayv2_api.otp_api.api_endpoint
}
output "send_otp_endpoint" {
description = "Full URL to the /send-otp endpoint."
value = "${aws_apigatewayv2_api.otp_api.api_endpoint}/send-otp"
}
output "verify_otp_endpoint" {
description = "Full URL to the /verify-otp endpoint."
value = "${aws_apigatewayv2_api.otp_api.api_endpoint}/verify-otp"
}
output "otp_table_name" {
description = "Name of the DynamoDB table used for storing OTPs."
value = aws_dynamodb_table.otp_table.name
}
Step 3: Deploy the Stack
Before deploying, ensure your Lambda function code is in the lambda directory. The Terraform configuration uses data "archive_file" to zip this directory automatically.
-
Initialize Terraform:
Run this command in the root directory of your project (where your
.tffiles are):terraform init -
Plan the deployment:
This will show you what resources Terraform will create.
terraform plan -
Apply Terraform configuration:
This will provision the resources in your AWS account. Confirm by typing
yeswhen prompted.terraform apply
After deployment, Terraform will output the API endpoint URLs. You can now make POST requests to:
https://.execute-api. .amazonaws.com/send-otp
Request Body:{ "userId": "+11234567890" }(use an E.164 formatted phone number)https://.execute-api. .amazonaws.com/verify-otp
Request Body:{ "userId": "+11234567890", "otp": "123456" }
To clean up and remove all created resources, run:
terraform destroy
Conclusion
Using Terraform, you've successfully defined and deployed a fully serverless, scalable, and secure OTP delivery system. This approach leverages AWS API Gateway for exposing endpoints, AWS Lambda for business logic, Amazon SNS for sending SMS, and Amazon DynamoDB for persistent OTP storage with automatic expiration. The entire infrastructure is managed as code, ensuring it is repeatable, version-controlled, and auditable.