As a software engineer with some knowledge in cybersecurity, I've always been conscious about the balance between convenience and control in the tools I use. Recently, I decided to overhaul the contact form on my personal blog by migrating from a third-party service to a custom-built solution.

In this post, I'll walk through my journey, explaining the architectural decisions, security considerations, and the technical implementation details of taking ownership of this seemingly simple but critical website component.

The Previous Setup: Third-Party Form Service

Initially, my contact form relied on Web3Forms, a common third-party form handling service.

Web3Forms — Easy Contact Form to Email Service
Receive html contact form submissions directly in your email inbox using our contact form api service without any server or backend code. Its free!

Basically, it is a HTML contact form looks like below:

The JavaScript code for the previous setup focused primarily on ensuring the hCaptcha was properly rendered and validating its response before submission, for the detailed guide, Web3Forms actually have a very detailed guide below:

hCaptcha | Web3Forms
hCaptcha is the privacy friendly alternative to Google reCaptcha. Used by Cloudflare, Shopify & more…

This approach relied entirely on Web3Forms to handle the form processing, email delivery, and spam protection. I just needed to include their access key, set up a redirect URL, and integrate hCaptcha for bot protection. While it worked, I had little visibility into what happened after the form was submitted.

Why I Decided to Build My Own Solution

While third-party services offer convenience, they come with limitations. I couldn't customize the form processing logic or add sophisticated validation rules beyond what the service provided. All form submissions went through a third-party server before reaching me, which raised data privacy concerns. Additionally, I was dependent on Web3Forms' reliability – if they experienced downtime or shut down, my contact form would stop working.

As someone who values both security and flexibility, I wanted more control over how user data was processed and stored. This led me to design a custom solution using tools I trust: Cloudflare Workers and n8n workflow automation.

The New Architecture: A Three-Tier Approach

My custom contact form architecture consists of three main components:

  1. Frontend Form: HTML/JavaScript form with client-side validation and hCaptcha integration
  2. Middleware Layer: Cloudflare Worker that handles form submission, security checks, and forwarding
  3. Backend Processing: n8n workflow that validates data, formats it, and sends emails

Frontend: HTML/JavaScript Form

Let's dive into the technical details of my new frontend implementation:

<form id="contactForm" method="POST" novalidate>
    <input type="hidden" name="form_key" value="my-form-key">
    <input type="hidden" name="subject" value="[Fanyang Meng's Blog] New Contact Form Submission">
    <label for="name">Your Name</label>
    <input name="name" id="name" type="text" />
    <label for="email">Your Email</label>
    <input name="email" id="email" type="email" required />
    <label for="message">Your Message</label>
    <textarea name="message" id="message" required></textarea>
    <div id="hCaptcha" class="h-captcha" data-captcha="true" data-sitekey="my-hcaptcha-site-key"></div>
    <button type="submit">Send message</button>
</form>

My latest JavaScript implementation uses a namespaced approach for better organization and more robust handling of hCaptcha in a single-page application environment:

// Create a namespace for my contact form functionality
window.mfyContactForm = window.mfyContactForm || {};
window.mfyContactForm.captchaWidgetId = null;
window.mfyContactForm.isInitialized = false;
window.mfyContactForm.captchaLoaded = false;

// This function will be called when hCaptcha is fully loaded
function onHcaptchaLoad() {
  console.log('hCaptcha API loaded');
  window.mfyContactForm.captchaLoaded = true;
  sessionStorage.setItem('hcaptchaLoaded', 'true');
  
  // Only initialize if not already done and we're on the contact page
  if (!window.mfyContactForm.isInitialized && document.getElementById('contactForm')) {
    window.mfyContactForm.initContactForm();
  }
}

This implementation takes a significantly more advanced approach to handling the captcha and form functionality. Let me break down the key improvements:

1. Namespaced Architecture

I've organized all the contact form functionality under a single namespace (window.mfyContactForm), which provides several benefits:

  • Prevents pollution of the global namespace
  • Reduces the risk of conflicts with other JavaScript libraries
  • Keeps related functionality logically grouped
  • Makes it easier to track state across different functions

This approach is especially important for blogs and websites where multiple JavaScript libraries might be running simultaneously.

2. Explicit hCaptcha Rendering

Instead of relying on automatic rendering, I now use the explicit rendering API which gives me more control:

// Function to set up captcha
window.mfyContactForm.setupCaptcha = function() {
  console.log('Setting up captcha');
  const hCaptchaDiv = document.getElementById('hCaptcha');
  
  // If div isn't found or hCaptcha isn't loaded yet, exit
  if (!hCaptchaDiv || !window.hcaptcha || !window.mfyContactForm.captchaLoaded) {
    console.log('Cannot setup captcha yet - missing div or API not loaded');
    return false;
  }
  
  // Clean up first
  window.mfyContactForm.cleanupCaptcha();
  
  // Render a new captcha
  try {
    window.mfyContactForm.captchaWidgetId = window.hcaptcha.render('hCaptcha', {
      sitekey: 'my-sitekey'
    });
    console.log('Captcha rendered with ID:', window.mfyContactForm.captchaWidgetId);
    return true;
  } catch (e) {
    console.error('Error rendering hCaptcha:', e);
    return false;
  }
};

The explicit rendering approach provides several advantages:

  • Better control over when and how the captcha is displayed
  • Proper cleanup between page navigations
  • Ability to track the captcha widget ID for proper removal
  • More robust error handling

3. Proper Cleanup for SPA Navigation

One of the biggest challenges with captchas in single-page applications is cleaning up properly between page transitions. My solution includes a dedicated cleanup function:

// Function to clean up captcha - safer version
window.mfyContactForm.cleanupCaptcha = function() {
  console.log('Cleaning up captcha');
  
  // Only attempt to remove if hCaptcha is loaded and we have a widget ID
  if (window.hcaptcha && window.mfyContactForm.captchaWidgetId !== null) {
    try {
      window.hcaptcha.remove(window.mfyContactForm.captchaWidgetId);
      console.log('Captcha widget removed');
    } catch (e) {
      console.log('Note: No active captcha to remove');
    }
    window.mfyContactForm.captchaWidgetId = null;
  }
  
  // Always clear the div
  const hCaptchaDiv = document.getElementById('hCaptcha');
  if (hCaptchaDiv) {
    hCaptchaDiv.innerHTML = '';
  }
};

This function is called during navigation events to ensure clean transitions between pages:

document.addEventListener('pvs.navigation.before-render', function() {
  console.log('Navigation event: before render');
  window.mfyContactForm.cleanupCaptcha();
  window.mfyContactForm.isInitialized = false;
});

4. Comprehensive SPA Navigation Handling

My implementation includes event handlers for different stages of single-page application navigation:

// Setup event handlers for client-side routing
document.addEventListener('pvs.navigation.content-rendered', function() {
  console.log('Navigation event: content rendered');
  
  // Only initialize if we're on the contact page
  if (document.getElementById('contactForm')) {
    // Give a slight delay to ensure the DOM is ready
    setTimeout(function() {
      window.mfyContactForm.initContactForm();
    }, 100);
  }
});

document.addEventListener('pvs.navigation.before-render', function() {
  console.log('Navigation event: before render');
  window.mfyContactForm.cleanupCaptcha();
  window.mfyContactForm.isInitialized = false;
});

This approach ensures that the form works correctly as users navigate through the site without full page reloads, preventing memory leaks and duplicate event handlers.

5. Form Submission Handler

The form submission handler has been simplified to focus on its core responsibility:

// Function to handle form submission
window.mfyContactForm.handleSubmit = function(e) {
  e.preventDefault();
  
  const form = e.target;
  const formData = new FormData(form);
  
  // Check if captcha is completed
  const hCaptchaResponse = formData.get('h-captcha-response');
  if (!hCaptchaResponse) {
    alert("Please complete the captcha");
    return;
  }
  
  // Show loading state
  const submitButton = form.querySelector('button[type="submit"]');
  const originalButtonText = submitButton.textContent;
  submitButton.disabled = true;
  submitButton.textContent = "Sending...";
  
  // Submit to Cloudflare Worker
  fetch('https://blog-contact-form-proxy.mfy.workers.dev/', {
    method: 'POST',
    body: formData
  })
  .then(response => response.json())
  .then(data => {
    if (data.success) {
      window.location.href = "https://fanyangmeng.blog/form-submitted-successfully/";
    } else {
      alert("Error: " + (data.error || "Unknown error"));
      // Reset button
      submitButton.disabled = false;
      submitButton.textContent = originalButtonText;
    }
  })
  .catch(error => {
    console.error('Error:', error);
    alert("There was an error submitting the form.");
    // Reset button
    submitButton.disabled = false;
    submitButton.textContent = originalButtonText;
  });
};

This function provides clear visual feedback during the submission process and handles errors gracefully.

Middleware Layer: Cloudflare Worker

My Cloudflare Worker serves as a security gateway between the frontend and backend processing. Let's examine the key components:

Request Handling and CORS

addEventListener("fetch", (event) => {
  const handleRequestAndAddCors = async (request) => {
    const response = await handleRequest(request);
    return addCorsHeaders(response);
  };
  
  event.respondWith(handleRequestAndAddCors(event.request));
});

function addCorsHeaders(response) {
  const newHeaders = new Headers(response.headers);
  newHeaders.set("Access-Control-Allow-Origin", self.ALLOWED_ORIGIN);
  newHeaders.set("Access-Control-Allow-Methods", "POST, OPTIONS");
  newHeaders.set("Access-Control-Allow-Headers", "Content-Type");
  
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers: newHeaders
  });
}

This code handles the incoming fetch event and ensures that proper CORS headers are added to every response. By explicitly setting Access-Control-Allow-Origin to my domain (stored in the ALLOWED_ORIGIN environment variable), I ensure that only my website can interact with this worker.

Rate Limiting with KV Namespace

One of the most interesting technical aspects is the rate limiting implementation using Cloudflare's KV namespace:

async function checkRateLimit(ip) {
  // Constants for rate limiting
  const MAX_REQUESTS = 5;           // Maximum requests allowed
  const TIME_WINDOW = 60 * 60;      // Time window in seconds (1 hour)
  const KEY_PREFIX = "ratelimit:";  // Prefix for KV store keys
  
  try {
    // Create a key for this IP
    const key = `${KEY_PREFIX}${ip}`;
    
    // Get current count for this IP
    let count = 0;
    let data = await RATE_LIMITS.get(key);
    
    if (data) {
      // Parse existing data
      data = JSON.parse(data);
      count = data.count;
      
      // If count already exceeds limit, rate limit is in effect
      if (count >= MAX_REQUESTS) {
        log("info", "Rate limit in effect", { ip, count });
        return true;
      }
    }
    
    // Increment the counter
    count++;
    
    // Store updated count with expiration
    await RATE_LIMITS.put(
      key,
      JSON.stringify({ count, ip }),
      { expirationTtl: TIME_WINDOW }
    );
    
    log("info", "Rate limit status", { ip, count, limit: MAX_REQUESTS });
    
    // Return false if under the limit, true if at/over the limit
    return count > MAX_REQUESTS;
  } catch (error) {
    // Log error but don't rate limit on errors
    log("error", "Rate limiting error", { 
      error: error.message, 
      ip 
    });
    return false;
  }
}

This implementation uses Cloudflare's KV (Key-Value) namespace, which is a globally distributed, eventually consistent key-value data store.

Get started · Cloudflare Workers KV
Workers KV provides low-latency, high-throughput global storage to your Cloudflare Workers applications. Workers KV is ideal for storing user configuration data, routing data, A/B testing configurations and authentication tokens, and is well suited for read-heavy workloads.

Here's how the rate limiting works:

  1. I create a unique key for each IP address with a prefix (ratelimit:123.45.67.89)
  2. I fetch the current count for that IP from the KV store
  3. If the count exists and exceeds the limit (5 requests per hour), I return true to indicate the rate limit is in effect
  4. Otherwise, I increment the counter and store it back with an expiration time (1 hour)
  5. The expirationTtl parameter automatically removes the entry after the time window, ensuring that users aren't permanently rate-limited

This approach is highly efficient because:

  • It uses Cloudflare's distributed KV store, which is designed for low-latency reads
  • The expiration is handled automatically without needing a cleanup process
  • It's stateless across worker invocations, allowing the worker to scale

hCaptcha Verification

Another crucial security component is server-side verification of hCaptcha tokens:

Developer Guide | hCaptcha
The hCaptcha widget can protect your applications from bots, spam, and other forms of automated abuse. Installing hCaptcha is fast and easy. It requires either adding some simple HTML and server side code, or using one of the many tools that natively support hCaptcha.
async function validateHCaptcha(token) {
  log("info", "Validating hCaptcha token", { tokenLength: token ? token.length : 0 });
  
  if (!token) {
    log("error", "Empty hCaptcha token");
    return false;
  }

  // Use URLSearchParams instead of FormData for more reliable server-to-server requests
  const params = new URLSearchParams();
  params.append("secret", self.HCAPTCHA_SECRET);
  params.append("response", token);
  
  try {
    log("info", "Sending verification to hCaptcha API");
    
    const verification = await fetch("https://hcaptcha.com/siteverify", {
      method: "POST",
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: params.toString()
    });
    
    if (!verification.ok) {
      const errorText = await verification.text();
      log("error", "hCaptcha API error", { 
        status: verification.status, 
        response: errorText 
      });
      return false;
    }
    
    const result = await verification.json();
    log("info", "Complete hCaptcha validation result", result);
    
    return result.success === true;
  } catch (error) {
    log("error", "hCaptcha validation error", { error: error.message });
    return false;
  }
}

This function performs a server-to-server verification of the hCaptcha token, ensuring it was actually solved by a human and not spoofed. The use of URLSearchParams over FormData is intentional, as it provides more reliable encoding for server-to-server requests.

Content Analysis for Spam Detection

I also implemented pattern-based spam detection:

function containsSuspiciousContent(message) {
  // Check for common spam patterns
  const spamPatterns = [
    /\b(?:viagra|cialis|casino|lottery|winner)\b/i,
    /\b(?:buy|cheap|free|offer|discount)\b.{0,20}\b(?:now|today|limited)\b/i,
    /\b(?:money|cash|dollar).{0,20}\b(?:guarantee|earn|make)\b/i,
    /\b(?:SEO|backlink|ranking)\b.{0,30}\b(?:service|offer|improve)\b/i
  ];
  
  return spamPatterns.some(pattern => pattern.test(message));
}

This function uses regular expressions to identify common spam patterns. The patterns target specific word combinations that appear frequently in spam messages. For example, the second pattern looks for marketing phrases like "buy now" or "cheap today" by using word boundaries (\b), allowing up to 20 characters between words (.{0,20}), and case-insensitive matching (/i).

Backend Processing: n8n Workflow

The final component is my n8n workflow. It has only three nodes: Webhook node, code node and send email node.

The code node provides additional validation and data formatting:

// Enhanced security validation and data formatting
const data = $input.first().json;

try {
  // Extract body data
  const body = data.body || {};
  
  // Basic validation with detailed error messages
  if (!body.email) {
    return { valid: false, reason: 'Missing email address' };
  }
  
  if (!body.message) {
    return { valid: false, reason: 'Missing message content' };
  }
  
  // Email format validation
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(body.email)) {
    return { valid: false, reason: 'Invalid email format' };
  }
  
  // Check for reasonable message length
  const MAX_MESSAGE_LENGTH = 5000;
  if (body.message.length > MAX_MESSAGE_LENGTH) {
    return { valid: false, reason: `Message exceeds maximum length of ${MAX_MESSAGE_LENGTH} characters` };
  }
  
  // Check for empty message after trimming whitespace
  if (body.message.trim().length === 0) {
    return { valid: false, reason: 'Message contains only whitespace' };
  }
  
  // Use timestamp from Cloudflare Worker if available
  const timestamp = body.timestamp 
    ? new Date(parseInt(body.timestamp)).toISOString() 
    : new Date().toISOString();
  
  // Format the data for email
  return { 
    valid: true,
    subject: body.subject || '[Blog] Contact Form Submission',
    name: body.name || 'Anonymous',
    email: body.email,
    message: body.message,
    timestamp: timestamp,
    metadata: {
      source: body.source || 'unknown',
      ip: body.ip || 'unknown',
      originalTimestamp: body.timestamp
    }
  };
} catch (error) {
  // Catch any unexpected errors
  return { 
    valid: false, 
    reason: `Processing error: ${error.message}`,
    error: error
  };
}

This n8n code node performs several important functions:

  1. It extracts the request body from the incoming webhook data
  2. It performs additional validation on email format and message content
  3. It handles potential errors and edge cases (empty messages, excessively long content)
  4. It normalizes the timestamp data
  5. It structures the data in a consistent format for the email node

What's particularly valuable here is that I can easily modify this code to add new validation rules or formatting requirements without touching the frontend or Cloudflare Worker.

Comparing the Two Approaches

Let's examine the key differences between my previous third-party solution and my custom implementation.

Security Considerations

My third-party solution relied entirely on Web3Forms' security practices, which were somewhat of a black box to me. While I could add hCaptcha to prevent bot submissions, I had no visibility into what additional security measures were in place. I couldn't customize security parameters or implement features like rate limiting.

With my custom solution, I've implemented multiple security layers with defense in depth. The frontend validates inputs before submission. The Cloudflare Worker adds origin validation, rate limiting, server-side hCaptcha verification, and content pattern analysis. The n8n workflow adds yet another validation layer before email delivery. Each component has clear security responsibilities, and I can adjust the security parameters as needed based on emerging threats.

The rate limiting implementation is particularly powerful. By using Cloudflare's KV namespace, I can track submission attempts across multiple edge locations globally. This prevents attackers from bypassing rate limits by rotating IP addresses or distribution requests across regions. The implementation is also efficient, with automatic cleanup of expired rate limit counters.

Control and Data Flow

With Web3Forms, the data flow was straightforward but opaque. Form submissions went directly to their servers, and I had little visibility into processing, validation, or error handling. My form data passed through third-party infrastructure before reaching me, raising potential privacy concerns.

My custom solution gives me complete visibility and control over the entire data flow. I can see exactly how data moves from the user's browser through my Cloudflare Worker to my n8n workflow and finally to my inbox. I've added detailed logging at each step, allowing me to trace issues and understand patterns in form submissions. All data stays within infrastructure that I control, except for the necessary hCaptcha verification step.

The code in my Cloudflare Worker is particularly important for controlling this data flow. The main handler function orchestrates the entire validation and forwarding process:

async function handleFormSubmission(request) {
  try {
    // Parse form data
    const formData = await request.formData();
    
    // Extract and validate required fields
    const name = formData.get("name") || "";
    const email = formData.get("email");
    const message = formData.get("message");
    const formKey = formData.get("form_key");
    const captchaResponse = formData.get("h-captcha-response");
    
    log("info", "Form submission received", { name, email });
    
    // Validate form key
    if (formKey !== self.FORM_SECRET_KEY) {
      log("warn", "Invalid form key", { receivedKey: formKey });
      return new Response(JSON.stringify({ 
        success: false, 
        error: "Invalid form submission" 
      }), {
        status: HTTP_FORBIDDEN,
        headers: { "Content-Type": "application/json" }
      });
    }
    
    // ... additional validation steps ...
    
    // Forward to n8n webhook with authentication
    const authHeader = "Basic " + btoa(`${self.N8N_AUTH_USERNAME}:${self.N8N_AUTH_PASSWORD}`);
    
    // Create a new FormData to send to n8n
    const n8nFormData = new FormData();
    for (const [key, value] of formData.entries()) {
      n8nFormData.append(key, value);
    }
    
    // Add additional metadata
    const timestamp = Date.now().toString();
    n8nFormData.append("timestamp", timestamp);
    n8nFormData.append("source", "cloudflare-worker");
    n8nFormData.append("ip", request.headers.get("CF-Connecting-IP") || "unknown");
    
    log("info", "Forwarding to n8n", { url: self.N8N_WEBHOOK_URL });
    const response = await fetch(self.N8N_WEBHOOK_URL, {
      method: "POST",
      headers: {
        "Authorization": authHeader
      },
      body: n8nFormData
    });
    
    // ... handle response and return to client ...
  } catch (error) {
    return handleError(error, "Error in form submission:");
  }
}

This function not only validates the submission but also enriches it with valuable metadata like IP address and timestamp. It uses HTTP Basic Authentication when forwarding to n8n, ensuring that only authorized requests are processed.

Maintenance and Reliability

The third-party solution required minimal maintenance – I just needed to keep my access key updated and ensure the form was properly integrated with Web3Forms. However, I was dependent on their service reliability and had limited debugging capabilities when issues arose.

My custom solution requires more active maintenance. I need to monitor and update three separate components: the frontend form, the Cloudflare Worker, and the n8n workflow. I need to ensure that environment variables are properly set and that each component is functioning correctly. However, this increased maintenance comes with significant benefits: complete visibility for debugging, the ability to update individual components without affecting others, and independence from third-party service availability.

The thorough debugging in my updated JavaScript implementation is particularly valuable for maintenance. By logging each step of the form initialization, captcha rendering, and submission process, I can quickly identify when and where issues occur. The form cloning technique also helps prevent subtle bugs from event listener duplication in single-page applications.

Why This Architecture Works

My three-tier architecture follows several best practices in security and systems design. The separation of concerns is clear: the frontend handles user interaction, the Cloudflare Worker handles security and forwarding, and n8n handles final processing and email delivery. Each component has a single responsibility, making the system easier to maintain and update.

By leveraging Cloudflare Workers as middleware, I get the benefits of edge computing – my security checks run close to the user, providing fast responses while filtering out malicious traffic before it reaches my backend. The rate limiting implementation using KV namespaces provides global protection against abuse with minimal performance impact.

Using n8n for the backend processing gives me a visual workflow builder while maintaining full control over the processing logic. The workflow can be easily modified and extended without touching the frontend or middleware layers.

Conclusion

Building a custom contact form solution might seem like overkill for a personal blog, but as a security-minded engineer, I value the control and peace of mind it provides. The architecture I've implemented offers stronger security, better privacy, and more flexibility than relying on third-party services.

The JavaScript implementation now provides enhanced robustness through its modular design, with separate functions for form handling, captcha rendering, and initialization. The clever technique of cloning the form to prevent event duplication ensures reliable operation even in complex single-page applications. The extensive logging helps me quickly identify and resolve any issues that might arise.

The Cloudflare Worker's rate limiting through KV namespaces provides efficient protection against abuse, and the n8n workflow ensures consistent data formatting and delivery. While this approach requires more initial setup and ongoing maintenance, the benefits far outweigh these costs for my specific needs.

I now have complete visibility into the entire contact form process, can implement custom logic at any stage, and don't have to worry about a third-party service disappearing. My updated JavaScript implementation also ensures a smoother user experience with better error handling and visual feedback during form submission.

For anyone considering a similar migration, I'd recommend evaluating the tradeoffs based on your specific needs. If security, flexibility, and control are important to you, a custom implementation might be worth considering. If simplicity and minimal maintenance are your priorities, third-party services still have their place.

What security improvements have you made to your website recently? I'd love to hear about your experiences in the comments below.