Developing a Secure, Scalable, Serverless OTP Service Using AWS SNS, Lambda, and DynamoDB

OTP Service Architecture Diagram

Fig: Architecture Diagram For OTP Service

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-otp and /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.

  1. Initialize Terraform:

    Run this command in the root directory of your project (where your .tf files are):

    terraform init
  2. Plan the deployment:

    This will show you what resources Terraform will create.

    terraform plan
  3. Apply Terraform configuration:

    This will provision the resources in your AWS account. Confirm by typing yes when 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.