Lab Overview
Build a two-step email verification and user registration pipeline. A user submits a form, receives a verification email, clicks the link, and only then is their record permanently saved to DynamoDB and the site owner notified. Unverified submissions automatically expire after 24 hours via DynamoDB TTL.
| Service | Purpose | Free Tier |
|---|---|---|
| Amazon SES | Sends the verification email to the user and notification to owner | 62,000 emails/mo free |
| Amazon DynamoDB | Stores PENDING registrations with TTL and CONFIRMED records permanently | 25 GB free forever |
| AWS Lambda (x2) | RegistrationHandler starts the flow; ConfirmationHandler completes it | 1M requests/mo free |
| Amazon API Gateway | POST /register starts flow; GET /confirm completes it from email link | 1M calls/mo free |
| Amazon S3 | Hosts register2.html and confirmed2.html | Free Tier |
| AWS IAM | Role granting Lambda permission to use SES and DynamoDB | Always free |
Architecture:
| # | What Happens | AWS Service |
|---|---|---|
| 1 | User submits name + email on register2.html | S3 / Browser |
| 2 | JavaScript POSTs to API Gateway POST /register | API Gateway |
| 3 | Lambda generates a UUID verification token and saves PENDING record | DynamoDB |
| 4 | Lambda sends a verification email to the user | SES |
| 5 | User clicks Verify My Email in the email | Email Client |
| 6 | Browser GETs API Gateway GET /confirm?token=UUID | API Gateway |
| 7 | Lambda marks record CONFIRMED, removes TTL, emails owner | DynamoDB + SES |
| 8 | Lambda redirects browser to confirmed2.html | S3 / Browser |
Step-by-Step Instructions
1
Amazon DynamoDB
Create the UserRegistrations Table
- DynamoDB → Tables → Create table
- Table name:
UserRegistrations - Partition key:
token(String) - Table settings: Customize → Capacity mode: On-demand → Create table
- Click the table → Additional settings tab → TTL → Enable
- TTL attribute name:
ttl→ Enable TTL
NOTE: The attribute name
ttl must match exactly what the Lambda writes. DynamoDB auto-deletes PENDING records after 24 hours.2
AWS IAM
Create the Lambda Execution Role
- IAM → Roles → Create role → AWS service → Lambda → Next
- Attach:
AmazonSESFullAccess - Attach:
AmazonDynamoDBFullAccess - Attach:
AWSLambdaBasicExecutionRole - Role name:
LambdaRegistrationRole→ Create role
3
AWS Lambda
Create RegistrationHandler
- Lambda → Create function → Author from scratch
- Name:
RegistrationHandler→ Runtime: Python 3.12 - Execution role: LambdaRegistrationRole → Create function
- Delete all code and paste the following. Replace SENDER_EMAIL and update API_BASE_URL after Step 4.
import json, boto3, uuid
from datetime import datetime, timezone, timedelta
ses = boto3.client('ses', region_name='us-east-1')
dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
table = dynamodb.Table('UserRegistrations')
SENDER_EMAIL = 'no-reply@cloudpracticelabs.org' # replace
API_BASE_URL = 'https://YOUR-API-ID.execute-api.us-east-1.amazonaws.com/prod' # replace after Step 4
def lambda_handler(event, context):
headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type'}
try:
body = json.loads(event.get('body', '{}'))
name = body.get('name', '').strip()
email = body.get('email', '').strip()
if not name or not email:
return {'statusCode': 400, 'headers': headers,
'body': json.dumps({'error': 'Name and email required.'})}
token = str(uuid.uuid4())
ts = datetime.now(timezone.utc).isoformat()
ttl_time = int((datetime.now(timezone.utc) + timedelta(hours=24)).timestamp())
confirm_url = f"{API_BASE_URL}/confirm?token={token}"
table.put_item(Item={'token': token, 'name': name, 'email': email,
'status': 'PENDING', 'timestamp': ts, 'ttl': ttl_time})
ses.send_email(
Source=SENDER_EMAIL, Destination={'ToAddresses': [email]},
Message={'Subject': {'Data': 'Please verify your email address'},
'Body': {'Html': {'Data':
f'<h2>Hi {name},</h2>'
f'<p>Click to verify your email:</p>'
f'<a href="{confirm_url}" style="background:#1e3a8a;color:white;padding:12px 24px;text-decoration:none;font-weight:bold;">Verify My Email</a>'
f'<p style="color:#888;">Link expires in 24 hours.</p>'
}}}
)
return {'statusCode': 200, 'headers': headers,
'body': json.dumps({'message': f'Verification email sent to {email}.'})}
except Exception as e:
print(f'Error: {str(e)}')
return {'statusCode': 500, 'headers': headers, 'body': json.dumps({'error': str(e)})}- Click Deploy
4
AWS Lambda
Create ConfirmationHandler
- Lambda → Create function →
ConfirmationHandler→ Python 3.12 → LambdaRegistrationRole - Delete all code and paste the following. Replace SENDER_EMAIL, OWNER_EMAIL, and SUCCESS_URL after Step 5.
import json, boto3, urllib.parse
from datetime import datetime, timezone
from boto3.dynamodb.conditions import Attr
ses = boto3.client('ses', region_name='us-east-1')
dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
table = dynamodb.Table('UserRegistrations')
SENDER_EMAIL = 'no-reply@cloudpracticelabs.org' # replace
OWNER_EMAIL = 'fred.loomis@gmail.com' # replace
SUCCESS_URL = 'http://YOUR-BUCKET-URL/confirmed2.html' # replace after Step 5
def lambda_handler(event, context):
params = event.get('queryStringParameters') or {}
token = params.get('token', '').strip()
if not token: return redirect('Missing token.')
try:
item = table.get_item(Key={'token': token}).get('Item')
if not item: return redirect('Link is invalid or has expired (24 hours).')
if item.get('status') == 'CONFIRMED':
return redirect(f"Already verified: {item.get('email')}")
name = item.get('name', 'User')
email = item.get('email', '')
ts = datetime.now(timezone.utc).isoformat()
table.update_item(Key={'token': token},
UpdateExpression='SET #s = :c, confirmedAt = :ts REMOVE #ttl',
ExpressionAttributeNames={'#s': 'status', '#ttl': 'ttl'},
ExpressionAttributeValues={':c': 'CONFIRMED', ':ts': ts},
ConditionExpression=Attr('status').eq('PENDING'))
ses.send_email(Source=SENDER_EMAIL, Destination={'ToAddresses': [OWNER_EMAIL]},
Message={'Subject': {'Data': f'New registration: {name}'},
'Body': {'Text': {'Data': f'Name: {name}
Email: {email}
Time: {ts}'}}})
return {'statusCode': 302, 'headers': {'Location': SUCCESS_URL}, 'body': ''}
except Exception as e:
print(f'Error: {str(e)}')
return redirect(str(e))
def redirect(msg):
return {'statusCode': 302,
'headers': {'Location': f'{SUCCESS_URL}?msg={urllib.parse.quote(msg)}'},
'body': ''}- Click Deploy
5
Amazon API Gateway
Create the API with Two Routes
- API Gateway → Create API → HTTP API → Build
- Integration: Lambda → RegistrationHandler → API name: RegistrationAPI → Next
- Method: POST → path: /register → Next → Stage: prod → Create
- Copy the Invoke URL from the summary page
Add GET /confirm
- Routes → Create → Method: GET → path:
/confirm→ Create - Click GET /confirm → Attach integration → Create and attach → Lambda → ConfirmationHandler → Create
Enable CORS
- CORS → Allow-Origin:
*→ Allow-Methods:POST, GET, OPTIONS→ Allow-Headers:content-type→ Save
Update RegistrationHandler
- Lambda → RegistrationHandler → Code tab
- Replace YOUR-API-ID in API_BASE_URL with your actual Invoke URL (keep only /prod at end, NOT /register)
- Click Deploy
6
Amazon S3
Upload register2.html and confirmed2.html
- Upload both files to your S3 bucket
- Lambda → ConfirmationHandler → Code tab
- Update SUCCESS_URL to your S3 website URL + /confirmed2.html
- Click Deploy
7
End-to-End Testing
Test the Complete Registration Flow
Happy path
- Open your S3 URL + /register2.html
- Enter name and email → click Send Verification Email
- DynamoDB → UserRegistrations → Explore items — PENDING record appears
- Check inbox — verification email arrives
- Click Verify My Email in the email
- Browser redirects to confirmed2.html showing success
- Owner inbox receives notification email
- DynamoDB record shows status: CONFIRMED, ttl attribute removed
Error path
- Click the verification link a second time
- You should see the already-verified notice — idempotency confirmed
TIP: If the form hangs: press F12 → Console tab and look for errors. The most common issue is CORS on API Gateway.
Verification Checklist
- DynamoDB table UserRegistrations created with On-demand capacity
- TTL enabled with attribute name: ttl
- LambdaRegistrationRole created with SES, DynamoDB, and CloudWatch permissions
- RegistrationHandler deployed — SENDER_EMAIL and API_BASE_URL correct (ends in /prod only)
- ConfirmationHandler deployed — SENDER_EMAIL, OWNER_EMAIL, and SUCCESS_URL correct
- API Gateway RegistrationAPI has POST /register and GET /confirm routes
- CORS saved: Allow-Origin *, Methods: POST GET OPTIONS
- register2.html and confirmed2.html uploaded to S3
- Form shows success message after submit
- Verification email arrives in inbox
- DynamoDB shows PENDING record immediately after submit
- Clicking link redirects to confirmed2.html with success message
- DynamoDB record updated to CONFIRMED — ttl attribute removed
- Owner notification email received
- Clicking link again shows already-verified message
What You Learned
- Token-based email verification — industry-standard pattern for confirming user identity
- DynamoDB TTL — automatic expiration of unconfirmed records without scheduled cleanup
- Conditional writes — ConditionExpression prevents replay attacks
- Two Lambda functions — single responsibility: register and confirm
- Two API routes — POST starts the flow, GET completes it from the email link
- 302 Redirect from Lambda — redirecting the browser to a confirmation page
- Idempotent API design — clicking the link twice is safe
- Full serverless registration pipeline — production-grade, zero servers
Lab Cleanup
IMPORTANT: Delete all resources when finished.
| # | Resource | How to Delete |
|---|---|---|
| 1 | DynamoDB Table | DynamoDB → Tables → UserRegistrations → Delete |
| 2 | RegistrationHandler | Lambda → RegistrationHandler → Actions → Delete |
| 3 | ConfirmationHandler | Lambda → ConfirmationHandler → Actions → Delete |
| 4 | API Gateway | API Gateway → RegistrationAPI → Actions → Delete |
| 5 | IAM Role | IAM → Roles → LambdaRegistrationRole → Delete |
| 6 | S3 Files | S3 → delete register2.html and confirmed2.html |
| 7 | CloudWatch Logs | CloudWatch → Log groups → delete RegistrationHandler and ConfirmationHandler groups |