Secure CV Access with OTP and AWS

A behind-the-scenes look at a privacy-first, serverless download workflow
Posted by Munish Mehta on Wednesday, June 18, 2025
A technical breakdown of how a privacy-first CV request system was designed using AWS Lambda, DynamoDB, SES, and SNS. cv access technical diagram

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 and expires
    • Verifies it matches the hashed value
    • Ensures it hasn’t expired or been used
  • Upon success:
    • Marks OTP as used: true
    • Updates the CV request to otp_status: VERIFIED
    • Returns a presigned S3 URL for CV download

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

MeasurePurpose
OTP Hashing (SHA-256)Avoid storing raw OTPs
DynamoDB TTL on OTPsAutomatic cleanup after expiry
Rate Limiting (IP-based)Prevent brute force or abuse
Single-use OTPsOTPs are deleted after successful use
Presigned S3 URLsPrevent direct linking or scraping
No raw secrets in logsSanitized logging with obfuscated IDs

💾 DynamoDB Table Design (Simplified)

cv-requests

FieldTypeDescription
request_idStringPrimary Key
emailStringGSI for querying
timestampNumberEpoch timestamp
otp_statusStringPENDING / VERIFIED / EXPIRED

otps

FieldTypeDescription
otp_idStringPrimary Key
otp_hashStringHashed OTP value
expiresNumberEpoch timestamp for expiration
request_idStringFK reference to cv-requests
usedBooleanTrue 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.



agent