
In a previous post, I introduced a privacy-first CV request flow that asks visitors to verify their identity before gaining access to my downloadable resume. In this follow-up, I’ll share a high-level technical walkthrough of how it’s implemented behind the scenes using serverless AWS infrastructure — with a focus on scalability, data minimization, and secure document delivery.
🧩 Architectural Overview
At its core, the solution uses:
- AWS Lambda / Netlify Functions for compute logic (OTP generation, validation, CV access)
- Amazon DynamoDB to store metadata about each CV request and OTPs
- Amazon SES for sending OTPs via email
- Amazon SNS for optional OTP SMS delivery
- Amazon S3 to host the CV securely
- API Gateway / Netlify Functions to expose endpoints
All components follow a lightweight, event-driven model — perfect for personal sites that occasionally receive spikes in traffic.
🔐 Step-by-Step Flow
1. Privacy Policy & Form Submission
- A frontend form collects:
name
,email
(required)phone
,company
,location
(optional)
- User must agree to a privacy policy before submitting
- Data is sent via POST to the backend (
/request-access
)
2. OTP Generation & Delivery
- Backend generates a random 6-digit OTP
- Hashes the OTP (e.g. using SHA256) before storing it
- Saves OTP metadata to the
otps
table:otp_id
,otp_hash
,expires
,used
,request_id
- Sends the OTP to the user:
- ✅ Email (via SES)
- ✅ SMS (via SNS), if phone was provided
3. CV Request Metadata Storage
- Another DynamoDB table (
cv-requests
) stores requestor details:request_id
(PK),name
,email
,phone
,timestamp
,otp_status
- This record acts as a link between OTP and the original request
4. OTP Verification Endpoint
- User enters OTP on a verification screen
/verify-access
endpoint:- Looks up the hashed OTP by
otp_id
andexpires
- Verifies it matches the hashed value
- Ensures it hasn’t expired or been used
- Looks up the hashed OTP by
- Upon success:
- Marks OTP as
used: true
- Updates the CV request to
otp_status: VERIFIED
- Returns a presigned S3 URL for CV download
- Marks OTP as
5. Secure File Delivery
- CV file lives in a private S3 bucket (not public)
- Download link is generated via S3 presigned URL
- Valid for only 2 minutes
- Link is shown once, immediately after OTP verification
🧠 Security Features
Measure | Purpose |
---|---|
OTP Hashing (SHA-256) | Avoid storing raw OTPs |
DynamoDB TTL on OTPs | Automatic cleanup after expiry |
Rate Limiting (IP-based) | Prevent brute force or abuse |
Single-use OTPs | OTPs are deleted after successful use |
Presigned S3 URLs | Prevent direct linking or scraping |
No raw secrets in logs | Sanitized logging with obfuscated IDs |
💾 DynamoDB Table Design (Simplified)
cv-requests
Field | Type | Description |
---|---|---|
request_id | String | Primary Key |
email | String | GSI for querying |
timestamp | Number | Epoch timestamp |
otp_status | String | PENDING / VERIFIED / EXPIRED |
otps
Field | Type | Description |
---|---|---|
otp_id | String | Primary Key |
otp_hash | String | Hashed OTP value |
expires | Number | Epoch timestamp for expiration |
request_id | String | FK reference to cv-requests |
used | Boolean | True if already verified |
🧪 Netlify Function Endpoints
The OTP generation and verification logic were deployed using Netlify Functions, making it simple to host serverless endpoints alongside the static Hugo site.
Two key endpoints power the CV workflow:
1. Api endpoint /request-access
Netlify / AWS Lambda function request-access.js
This function:
- Accepts user details via POST
- Validates email and phone
- Generates a 6-digit OTP
- Sends OTP via SES and SNS
- Stores hashed OTP + request metadata in DynamoDB
exports.handler = async (event) => {
const { name, email, phone } = JSON.parse(event.body);
const otp = generate6DigitOtp();
const otpHash = sha256(otp);
// Save to DynamoDB
await dynamodb.put({
TableName: "otps",
Item: {
otp_id: uuid(),
otp_hash: otpHash,
expires: Date.now() + 600_000, // 10 min
request_id,
used: false
}
}).promise();
// Send OTP via SES and optionally via SNS
await ses.sendEmail(...);
if (phone) await sns.publish(...);
return {
statusCode: 200,
body: JSON.stringify({ message: "OTP sent" })
};
};
2. Api endpoint /verify-access
Netlify / AWS Lambda function verify-access.js
This function:
- Accepts the submitted OTP
- Looks up the matching hashed value in DynamoDB
- Verifies expiry and usage
- Returns a secure S3 presigned link for CV download
exports.handler = async (event) => {
const { otp } = JSON.parse(event.body);
const hashed = sha256(otp);
const result = await dynamodb.query({
TableName: "otps",
IndexName: "otp_hash-index", // if indexed
KeyConditionExpression: "otp_hash = :h",
ExpressionAttributeValues: { ":h": hashed }
}).promise();
const record = result.Items?.[0];
if (!record || record.used || Date.now() > record.expires) {
return { statusCode: 400, body: "Invalid or expired OTP" };
}
await dynamodb.update(...); // Mark as used
const url = s3.getSignedUrl("getObject", { Bucket: ..., Key: ..., Expires: 120 });
return {
statusCode: 200,
body: JSON.stringify({ redirectUrl: url })
};
};
These functions were deployed inside the Hugo project under netlify/functions/, with automatic URL paths like:
https://<mysite-subdomain>.netlify.app/.netlify/functions/request-access
https://<mysite-subdomain>.netlify.app/.netlify/functions/verify-access
Later, these can be ported to AWS Lambda + API Gateway with minimal changes.
🧰 Dev & Deployment Tips
- ✅ Use environment variables for SES/SNS credentials
- ✅ Use IAM roles with minimal permissions for Lambda
- ✅ Use Terraform or Serverless Framework to manage infra
- ✅ Log only request IDs (not OTPs, emails, or phone numbers)
- ✅ Use a distinct IAM user for email and SMS services
📘 What’s Next?
While this solution was built for CV access, it can be easily adapted for:
- Download gating (ebooks, reports)
- Secure client document sharing
- Privacy-compliant contact forms
It’s lightweight, scalable, and respects users by asking for consent before collecting data.
✅ Conclusion
Designing this workflow was a valuable exercise in user-respectful access control. Instead of defaulting to “open access,” it asks for minimal, consented information and protects it with hashed, expiring, single-use tokens — all using cost-effective AWS primitives.
If you’re a developer or freelancer sharing personal material online, consider adopting a similar approach. It not only keeps you compliant but also earns user trust.