Contact Form for Static Sites Using Cloudflare Workers and Airtable

Static sites have no backend to process form submissions, making contact forms more difficult than on traditional server-rendered sites. This article builds a spam-protected contact form using a static HTML form, a Cloudflare Worker to process submissions, and Airtable to store messages and send e-mail notifications. For a low-traffic site, the solution fits within the free tier for both Cloudflare and Airtable.

Spam Protection

This implementation combines several independent techniques (defense-in-depth) rather than relying on a single filter.

While each technique can be bypassed individually, together they substantially reduce unwanted automated submissions while requiring very little infrastructure and no end-user friction (for example, CAPTCHA).

Instructions

Step 1: Create Contact Form

Start by building a basic HTML form with the following fields (all required): Name, Email, Subject, and Message.

Add honeypot and nonce fields, which can both be hidden with CSS.

The nonce field is populated automatically by JavaScript once when the page loads. The nonce (“number used once”) is an HMAC-signed cryptographic token generated by the Cloudflare Worker that helps verify the request originated from the site’s own form. Because the token is signed and includes a timestamp, the Worker can reject requests that contain invalid, tampered, or expired values without maintaining any server-side session state, and it can later measure how long the visitor took to fill out and submit the form.

For simplicity, no styling is applied to the form.

<form id="contactForm" aria-label="Contact form">
  <label for="name">Name</label>
  <input type="text" id="name" name="name" placeholder="" value="" required>
  <label for="email">Email</label>
  <input type="email" id="email" name="email" placeholder="" value="" required>
  <label for="subject">Subject</label>
  <input type="text" id="subject" name="subject" placeholder="" value="" required>
  <label for="message">Message</label>
  <textarea id="message" name="message" placeholder="" required></textarea>
  <div class="visually-hidden">
    <label for="nonce">Nonce</label>
    <input type="text" id="nonce" name="nonce" placeholder="" value="">
    <label for="honeypot">Do Not Use</label>
    <textarea id="honeypot" name="honeypot"></textarea>
  </div>
  <button id="submit-button" type="submit">Send Message</button>
</form>
<div id="response" role="status"></div>

The <form> element does not include action or method attributes. Instead, use JavaScript to:

When the page loads, JavaScript requests a fresh nonce before the form can be submitted.

let isSubmitted = false;

document.addEventListener('DOMContentLoaded', async function () {
  const nonceResponse = await fetch('/contact/nonce/');
  const nonce = await nonceResponse.text();
  document.getElementById('nonce').value = nonce;

  document.getElementById('contactForm').addEventListener('submit', async (e) => {
    e.preventDefault();

    if (!isSubmitted) {
      isSubmitted = true;
      document.getElementById('submit-button').disabled = true;
      document.getElementById('submit-button').classList.add('disabled');
      document.getElementById('response').textContent = '';

      const formData = new FormData(document.getElementById('contactForm'));

      try {
        const response = await fetch('/contact/submit/', {
          method: 'POST',
          body: formData,
        });

        if (!response.ok) {
          throw new Error(response.status);
        }

        const responseText = await response.text();
        document.getElementById('response').textContent = responseText;
      } catch (error) {
        document.getElementById('response').textContent = `An error (${error.message}) occurred while submitting the form.`;
        isSubmitted = false;
        document.getElementById('submit-button').disabled = false;
        document.getElementById('submit-button').classList.remove('disabled');
      }
    }

    return;
  });

  return;
});

Step 2: Set Up Airtable (Workspace, Base, Table, and API Keys)

Use Airtable to collect form data and send e-mail notifications. Airtable serves as both a lightweight database and an automation platform capable of sending notification e-mails without requiring additional infrastructure.

  1. Sign in or create an Airtable account.
  2. Create a new workspace (for example, Cloudflare Workers).
Screenshot of the Airtable workspaces screen.
Airtable: Workspaces Screen
  1. Add a base named Messages, and create a table also called Messages.
Screenshot of a new Airtable base and table.
Airtable: New Base and Table Screen
  1. Add these fields to the table:
Screenshot of the Airtable table fields configuration screen.
Airtable: Table Fields Configuration Screen
  1. Click the Help button and open API documentation. In the Introduction section, note the ID for the Messages base. This will be referred to as AIRTABLE_BASE_ID in a later step.
Screenshot of the Airtable API documentation for base screen.
Airtable: API Documentation for Base Screen
  1. In the MESSAGES TABLE section, note the ID for the Messages table. This will be referred to as AIRTABLE_TABLE_ID in a later step.
Screenshot of the Airtable API documentation for table screen.
Airtable: API Documentation for Table Screen
  1. Go to the Personal access tokens page and Create new token. Name the token and add the scope data.records:write with access to the appropriate base. Click the Create token button and note the TOKEN ID. This will be referred to as AIRTABLE_ACCESS_TOKEN in a later step.
Screenshot of the Airtable Builder Hub Personal Access Token screen.
Airtable: Builder Hub Personal Access Token Screen
  1. Optional but recommended: Return to the Messages base and click Automations. Create an automation to send a notification e-mail to a verified e-mail address each time a new entry is created in the Messages table.
Screenshot of the Airtable Automation screen.
Airtable: Automation Screen

In the Send an email action, configure the notification to include the preferred subject line and fields from the Messages table as well as automation timing.

Screenshot of the Airtable Automation screen to send an e-mail notification when a new record is created.
Airtable: Automation To Send E-mail When New Message Record Is Created

Step 3: Create a Cloudflare Worker

Now, create the backend service that connects the HTML form to Airtable.

  1. Log in to Cloudflare.
  2. Click Compute and go to Workers & Pages and click the Create button.
  3. Click the Create application button and select Start with Hello World!.
  4. Rename the project to something more meaningful and click the Deploy button.
  5. On the dashboard, click the Domains tab to configure Custom Domains and Routes.

Disable workers.dev and Preview URLs routes.

Define two custom routes (non-wildcard) specific to the site:

Screenshot of a the Cloudflare Workers Domains and Routes configuration screen.
Cloudflare Workers: Domains & Routes Configuration
  1. From the Settings tab, under Variables and secrets, define four new environment variables of type Text: AIRTABLE_ACCESS_TOKEN, AIRTABLE_BASE_ID, and AIRTABLE_TABLE_ID (using the values noted from Airtable), and CONTACT_FORM_SECRET (a random string used to sign the anti-spam nonce). Click the Deploy button.

NOTE: The value for CONTACT_FORM_SECRET should be a random string that only the Worker knows. It is not tied to Airtable or any external service. It exists purely to sign and verify the nonce, so its only requirement is high entropy and secrecy. The command openssl rand -hex 32 produces a 64-character random hex string or, if openssl is not available, Node’s crypto.randomBytes(32).toString('hex') works equally well.

Screenshot of a the Cloudflare Workers Variables and Secrets configuration screen.
Cloudflare Workers: Variables and Secrets Configuration
  1. From the dashboard, open the code editor.
  2. Replace the template code with the following code.
const encoder = new TextEncoder();
let hmacKey = null;

const CONTACT_BASE_URL = 'https://johndalesandro.com/contact/';
const NONCE_URL = `${CONTACT_BASE_URL}nonce/`;
const SUBMIT_URL = `${CONTACT_BASE_URL}submit/`;

async function getHmacKey(secret) {
  if (!hmacKey) {
    hmacKey = await crypto.subtle.importKey(
      'raw',
      encoder.encode(secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign', 'verify'],
    );
  }
  return hmacKey;
}

async function signPayload(secret, payload) {
  const key = await getHmacKey(secret);
  const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(payload));
  return btoa(String.fromCharCode(...new Uint8Array(signature)));
}

async function issueNonce(env) {
  const timestamp = Date.now().toString();
  const signature = await signPayload(env.CONTACT_FORM_SECRET, timestamp);
  return `${timestamp}.${signature}`;
}

function base64ToBytes(base64) {
  return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}

async function verifyNonce(env, token) {
  if (!token || !token.includes('.')) {
    return null;
  }
  const [timestamp, signature] = token.split('.');

  let signatureBytes;
  try {
    signatureBytes = base64ToBytes(signature);
  } catch {
    return null;
  }

  const key = await getHmacKey(env.CONTACT_FORM_SECRET);
  const isValid = await crypto.subtle.verify(
    'HMAC',
    key,
    signatureBytes,
    encoder.encode(timestamp),
  );

  if (!isValid) {
    return null;
  }
  return Number(timestamp);
}

function formatDuration(ms) {
  const totalSeconds = Math.floor(ms / 1000);
  const minutes = Math.floor(totalSeconds / 60);
  const seconds = totalSeconds % 60;

  if (minutes === 0) {
    return `${seconds} second${seconds === 1 ? '' : 's'}`;
  }
  return `${minutes} minute${minutes === 1 ? '' : 's'} ${seconds} second${seconds === 1 ? '' : 's'}`;
}

function hasValidReferer(request) {
  const referer = request.headers.get('referer');
  if (!referer) {
    return false;
  }
  try {
    return new URL(referer).toString() === CONTACT_BASE_URL;
  } catch {
    return false;
  }
}

function failure(reason) {
  return new Response(`Message Failed: ${reason}`, { status: 200 });
}

async function createRecord(env, body) {
  return fetch(
    `https://api.airtable.com/v0/${env.AIRTABLE_BASE_ID}/${encodeURIComponent(env.AIRTABLE_TABLE_ID)}`,
    {
      method: 'POST',
      body: JSON.stringify(body),
      headers: {
        Authorization: `Bearer ${env.AIRTABLE_ACCESS_TOKEN}`,
        'Content-Type': 'application/json',
      },
    },
  );
}

export default {
  async fetch(request, env) {
    if (!hasValidReferer(request)) {
      return failure('Flagged As Spam (Invalid Referer)');
    }

    const requestURL = new URL(request.url);

    if (request.method === 'GET' && requestURL.toString() === NONCE_URL) {
      const nonce = await issueNonce(env);
      return new Response(nonce, { status: 200 });
    }

    if (request.method !== 'POST') {
      return failure('Method Not Allowed');
    }
    if (requestURL.toString() !== SUBMIT_URL) {
      return failure('Endpoint Not Found');
    }

    try {
      const body = await request.formData();
      const { name, email, subject, message, nonce, honeypot } = Object.fromEntries(body);

      if (honeypot) {
        return failure('Flagged As Spam (Honeypot)');
      }

      const trimmedName = name?.trim();
      const trimmedEmail = email?.trim();
      const trimmedSubject = subject?.trim();
      const trimmedMessage = message?.trim();

      if (!trimmedName || !trimmedEmail || !trimmedSubject || !trimmedMessage) {
        return failure('Invalid Input');
      }
      if (
        trimmedName.length > 200 ||
        trimmedEmail.length > 200 ||
        trimmedSubject.length > 200 ||
        trimmedMessage.length > 50000
      ) {
        return failure('Invalid Input');
      }
      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
        return failure('Invalid Input');
      }

      const issuedAt = await verifyNonce(env, nonce);
      if (issuedAt === null) {
        return failure('Flagged As Spam (Invalid Nonce)');
      }

      const elapsedMs = Date.now() - issuedAt;
      if (elapsedMs < 5000) {
        return failure('Flagged As Spam (Timestamp Duration)');
      }
      if (elapsedMs > 3600000) {
        return failure('Flagged As Spam (Nonce Expired)');
      }

      const ip = request.headers.get('CF-Connecting-IP') ?? 'Unknown';

      const reqBody = {
        fields: {
          Name: trimmedName,
          Email: trimmedEmail,
          Subject: trimmedSubject,
          Message: trimmedMessage,
          IP: ip,
          Duration: formatDuration(elapsedMs),
        },
      };

      const response = await createRecord(env, reqBody);
      if (!response.ok) {
        const errorBody = await response.text();
        console.error(`Airtable Error: ${errorBody}`);
        return failure('Error Sending Message');
      }

      return new Response('Message Sent Successfully', { status: 200 });
    } catch (error) {
      console.error('Worker Error: ', error);
      return failure('Error Sending Message');
    }
  },
};

Before deploying, adjust CONTACT_BASE_URL to match the site’s contact page.

The Worker’s fetch() handler checks the referer on every request, then branches on method and path:

Both paths must match the two routes defined in Domains & Routes above.

Click the Deploy button.

Results

Test the form by submitting a message.

Screenshot of a completed contact form with test data.
Contact Form Test Data

A confirmation response like Message Sent Successfully appears under the form.

Screenshot of a contact form submission with a successful response message.
Contact Form Success Message

Check the Airtable base. A new record is created in the Messages table.

Screenshot from Airtable of the Messages table containing a new record.
Airtable: New Messages Record Created

If automation is enabled, an e-mail notification with the submitted details will be received.

Screenshot of the e-mail notification received from the Airtable automation.
Airtable E-mail Notification

Summary

This article walks through building a free, serverless contact form for a static site using HTML, JavaScript, Cloudflare Workers, and Airtable. Layering several independent, lightweight checks provides meaningful protection against automated abuse without requiring a traditional backend, a CAPTCHA, or any end-user friction.