Skip to main content

Web Form to Discord With CloudFlare Turnstile Captcha

So I set up a TF2 server a while back, and password protected it. I use it mainly for my own enjoyment, but figured sometimes friends or family would want to play. It's got bots that auto-bail when real players join, keeping it full at 24 players at all times. Anyhow, I figured I would set up a site about it for a number of reasons, and did some other cool stuff, like the kill log scrolls, a list of maps show, etc.

One of the things I implemented was a small HTML form on the index page to allow users to request a password to the server. The form is filled out and submitted, and the info is sent to a channel in my Discord server. Natrually, I also had to protect it, and I opted to use somethign a little more complicated to protect the form: CloudFlare Turnstile. Here is how I did it:

General Information

This method avoids exposing sensitive information like your Discord webhook URL on the frontend. It uses a Cloudflare Worker as a secure backend to process the data, and Cloudflare Turnstile as a free, user-friendly CAPTCHA to prevent spam.

Prerequisites

Before you begin, you'll need:

  • A website that is managed through a Cloudflare account.

  • A Discord server where you have permission to create webhooks.

  • An HTML form on your website that you want to connect.

Create a Discord Webhook

This webhook is the destination for your form submissions.

  1. In your Discord server, go to Server Settings > Integrations.

  2. Click on Webhooks > New Webhook.

  3. Give the webhook a name (e.g., "Website Submissions") and choose the channel it should post to.

  4. Click Copy Webhook URL.

  5. Important: Keep this URL safe and private. It's a secret key.

Set Up Cloudflare Turnstile (CAPTCHA)

Turnstile will protect your form from bots without annoying challenges.

  1. In your Cloudflare Dashboard, go to Turnstile in the left sidebar.

  2. Click Add site.

  3. Give your site a name, enter your website's domain, and choose the "Managed" widget type.

  4. Click Create.

  5. Cloudflare will give you a Site Key and a Secret Key. Copy both of these—you'll need them soon.

Step 3: Create the Cloudflare Worker (The Backend)

The worker is a serverless function that will act as the secure middleman. It receives data from your form, validates the CAPTCHA, and then sends the data to Discord.

  1. In the Cloudflare Dashboard, go to Workers & Pages > Create application.

  2. Give your worker a name (e.g., form-processor) and click Deploy.

  3. Click Edit code and paste the entire script below, replacing the default code.

Worker Script (index.js)

// Cloudflare Worker to process form submissions
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
  // Set CORS headers to allow requests ONLY from your website
  const corsHeaders = {
    'Access-Control-Allow-Origin': 'https://YOUR_WEBSITE_DOMAIN.com', // <-- IMPORTANT: Change this!
    'Access-Control-Allow-Methods': 'POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type',
  };

  // Handle CORS preflight requests
  if (request.method === 'OPTIONS') {
    return new Response(null, { headers: corsHeaders });
  }

  // Only allow POST requests
  if (request.method !== 'POST') {
    return new Response(JSON.stringify({ ok: false, error: 'method_not_allowed' }), { status: 405, headers: corsHeaders });
  }

  try {
    const payload = await request.json();
    const ip = request.headers.get('CF-Connecting-IP');

    // --- 1. Verify the Turnstile CAPTCHA Token ---
    let formData = new FormData();
    formData.append('secret', TURNSTILE_SECRET_KEY); // This is a secret variable, not plain text
    formData.append('response', payload['cf-turnstile-response']);
    formData.append('remoteip', ip);

    const turnstileUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
    const turnstileResult = await fetch(turnstileUrl, { body: formData, method: 'POST' });
    const turnstileOutcome = await turnstileResult.json();

    if (!turnstileOutcome.success) {
      return new Response(JSON.stringify({ ok: false, error: 'captcha_invalid' }), { status: 403, headers: corsHeaders });
    }

    // --- 2. If CAPTCHA is valid, format and send the data to Discord ---
    const discordPayload = {
      embeds: [{
        title: '✅ New Form Submission',
        color: 0x5865F2, // Discord blurple color
        fields: [
          // This example assumes 'name' and 'message' fields in the form.
          // Customize this section to match your form's fields.
          { name: 'Submitter Name', value: payload.name || 'N/A', inline: true },
          { name: 'Submitter Email', value: payload.email || 'N/A', inline: true },
          { name: 'Message', value: "```" + (payload.message || 'None') + "```", inline: false },
          { name: 'IP Address', value: ip || 'N/A', inline: false },
        ],
        timestamp: new Date().toISOString(),
      }],
    };

    const discordResponse = await fetch(DISCORD_WEBHOOK_URL, { // This is a secret variable
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(discordPayload),
    });

    if (!discordResponse.ok) {
        return new Response(JSON.stringify({ ok: false, error: 'discord_error' }), { status: 500, headers: corsHeaders });
    }

    // --- 3. Return a success response to the website ---
    return new Response(JSON.stringify({ ok: true }), { status: 200, headers: corsHeaders });

  } catch (error) {
    return new Response(JSON.stringify({ ok: false, error: 'internal_server_error' }), { status: 500, headers: corsHeaders });
  }
}

Add Secrets to the Worker

Never paste secrets directly into your code.

  1. In your worker's dashboard, go to Settings > Variables.

  2. Under Environment Variables, click Add variable and create two secrets:

    • DISCORD_WEBHOOK_URL: Paste the webhook URL from Step 1.

    • TURNSTILE_SECRET_KEY: Paste the Secret Key from Step 2.

  3. Click Save and deploy.

Configure the Frontend (HTML & JavaScript)

Now, update your website's HTML and add the JavaScript that sends the form data to your new worker.

HTML Form

Ensure your HTML includes:

  1. The Cloudflare Turnstile script in the <head>.

  2. A <div> for the Turnstile widget inside your form.

  3. An element to display status messages (success or error).

  4. An onsubmit attribute on the <form> tag to call our JavaScript function.

<!DOCTYPE html>
<html>
<head>
    <title>Contact Us</title>
    <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body>

    <form id="contact-form" onsubmit="handleFormSubmit(event)">
        <label for="name">Name:</label>
        <input type="text" id="name" name="name" required>

        <label for="email">Email:</label>
        <input type="email" id="email" name="email" required>

        <label for="message">Message:</label>
        <textarea id="message" name="message" required></textarea>

        <div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITE_KEY_HERE"></div>

        <button type="submit" id="submit-btn">Send Message</button>

        <p id="form-status" aria-live="polite"></p>
    </form>

    <script src="/path/to/your/form-handler.js"></script>

</body>
</html>

Client-Side JavaScript (form-handler.js)

This script handles the submission process in the browser.

async function handleFormSubmit(event) {
  event.preventDefault(); // Prevent the default browser form submission

  const form = event.target;
  const statusEl = document.getElementById('form-status');
  const submitBtn = document.getElementById('submit-btn');

  // Disable button and show sending message
  submitBtn.disabled = true;
  statusEl.textContent = 'Sending...';
  statusEl.style.color = 'gray';

  const formData = new FormData(form);
  const data = Object.fromEntries(formData.entries());

  // The URL for our worker, which we will route in the next step
  const workerUrl = '/api/submit-form';

  try {
    const response = await fetch(workerUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(data),
    });

    const result = await response.json();

    if (result.ok) {
      statusEl.textContent = 'Success! Your message has been sent.';
      statusEl.style.color = 'green';
      form.reset(); // Clear the form
      // Reset the CAPTCHA widget
      if (window.turnstile) window.turnstile.reset();
    } else {
      // Display specific errors from the worker
      let errorMessage = 'An error occurred. Please try again.';
      if (result.error === 'captcha_invalid') {
        errorMessage = 'CAPTCHA validation failed. Please try again.';
      }
      statusEl.textContent = errorMessage;
      statusEl.style.color = 'red';
    }
  } catch (error) {
    statusEl.textContent = 'A network error occurred. Please try again later.';
    statusEl.style.color = 'red';
  } finally {
    // Re-enable the button after a short delay
    setTimeout(() => { submitBtn.disabled = false; }, 2000);
  }
}

Connect Everything with a Worker Route

The final step is to tell Cloudflare that any requests to a specific URL path on your site should be handled by your worker.

  1. In the main Cloudflare dashboard, select your domain.

  2. On the left sidebar, click on Workers Routes.

  3. Click Add route.

  4. Fill out the form:

    • Route: *your.website.com/api/submit-form (Match the domain and the path used in your JavaScript's fetch call).

    • Service: Select the form-processor worker you created in Step 3.

    • Environment: production

  5. Click Save.

And you're done! Your form is now securely connected to your Discord channel. Submissions will appear in near real-time, protected from spam bots.