A Cloudflare Worker that securely proxies contact form submissions from static sites (e.g., Hugo and Cloudflare Pages) to Google Forms, while protecting against spam and abuse.
It includes:
- Nonce-based request validation
- Optional Cloudflare Turnstile CAPTCHA verification
- Basic rate limiting via KV storage
- CORS protection and form timing checks
- 🚀 Overview
- 🧩 Architecture
- 📋 Requirements
- 🧱 Example Hugo Integration Files
- 📝 Google Form Fields & Google Sheets
- 🌐 Multi-Domain Support
- 🛣️ Worker Routes (Cloudflare)
- 🔀 API Endpoints
- ⚙️ Setup
- 🧪 Development Tips and Testing
- 🔒 Security Features
⚠️ Disclaimer- 📄 License
Your site’s contact form does not directly post to Google Forms. Instead, it sends data to this Worker (e.g. https://example.com/api/contact), which:
- Issues a one-time nonce via /api/form-nonce
- Accepts the form POST to /api/contact
- Validates nonce + optional Turnstile token
- Submits data to Google Forms securely
This ensures no direct exposure of your Google Form endpoint or reCAPTCHA key.
Before deploying this Worker, make sure you have:
- A website built with Hugo - Ideally hosted on Cloudflare Pages for easiest integration and same-origin Worker calls. (Netlify or other static hosts will work too — you’ll just need to adjust CORS_ALLOW_ORIGIN.)
- A Google Form configured as your backend data collector (details below).
- A Cloudflare Turnstile site key + secret key - Used for spam and bot protection. You can use “Managed” or “Invisible” mode. You should add all the hostnames (domains) here.
This is what a Contact form looks like with the Turnstile widget visible, just above the "Send Message" button:
To make it easier, you can copy a folder like examples/hugo/ to your project repo, containing ready-to-copy templates for Hugo users:
examples/hugo/
├── config/
│ └── _default/
│ └── params.toml # Configures worker URL, Turnstile key, etc.
├── layouts/
│ ├── shortcodes/
│ │ └── contact-form.html # Core contact form logic
│ └── partials/
│ └── extend-head.html # CSP meta for Turnstile and worker
└── content/
└── contact.md # Example Hugo page embedding the form
Example files for Hugo can be found here: /examples/hugo/
[contactForm]
workerBase = "https://example.com"
proxyAction = "/api/contact"
proxyNonce = "/api/form-nonce"
useTurnstile = true
turnstileSiteKey = "0x4AAAAAAE37xyprqv9kTWM8"
minFillMs = 1200
buttonText = "Send Message"
successTitle = "Thank you for your message!"
successBody = "I'll get back to you as soon as possible."See full example at: /examples/hugo/layouts/shortcodes/contact-form.html
This file defines the Content Security Policy (CSP) to allow connections between your site, Turnstile, and the Worker. Your Hugo theme may use a different file name (e.g. custom-head.html). Adjust as needed.
{{- $cfg := .Site.Params.contactForm -}}
<meta http-equiv="Content-Security-Policy"
content="
default-src 'self';
connect-src 'self' {{ $cfg.workerBase }} https://challenges.cloudflare.com;
script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com;
style-src 'self' 'unsafe-inline';
frame-src https://challenges.cloudflare.com;
form-action 'self' {{ $cfg.workerBase }};
">---
title: "Contact"
layout: "page"
---
We’d love to hear from you!
{{< contact-form >}}This Worker expects a small, consistent payload from your site. You can capture these fields in your Google Form and map them via GFORM_MAP.
| Logical key | Purpose | Notes |
|---|---|---|
name |
Sender’s name | Text field |
email |
Sender’s email | “Short answer” |
message |
Message body | “Paragraph” |
source |
Site or app name | Optional, helpful if you have multiple sites |
pageurl |
Page where the form lives | Auto-filled by JS |
useragent |
Browser user agent | Auto-filled by JS |
start_ts |
Timestamp when user started form | Used for spam prevention |
cf-turnstile-response |
CAPTCHA token | Present only if Turnstile enabled |
Only name, email, and message are strictly required, but including all helps debugging or analytics.
In wrangler.toml:
[vars]
# Google Form endpoint (your form’s "formResponse" URL)
GFORM_ACTION = "https://docs.google.com/forms/u/0/d/e/1FAIpQLSXXXXXXXXXXXXXXX/formResponse"
# Map your Google Form field IDs (replace with your real ones)
GFORM_MAP = "{\"name\":\"entry.1234567890\",\"email\":\"entry.2345678901\",\"message\":\"entry.3456789012\",\"source\":\"entry.4567890123\",\"pageurl\":\"entry.5678901234\",\"useragent\":\"entry.6789012345\"}"- Open your form in Preview.
- Fill it out, then check DevTools → Network → formResponse.
- Look under Form Data for each entry.xxxxxxxx key.
- Copy these into GFORM_MAP.
You can automatically pipe every Worker-submitted response into a Google Sheet:
- Open your Form in edit mode.
- Go to Responses → Link to Sheets → Create a new spreadsheet.
- Submissions will now appear automatically.
If you later add fields, just create new questions → find their entry.xxxxxxxx → update GFORM_MAP.
This Worker supports multiple domains (e.g., example.com, example2.com) — useful if you operate more than one Hugo site that shares the same backend Worker.
In wrangler.toml:
[vars]
CORS_ALLOW_ORIGIN = "https://example.com,https://www.example.com,https://example2.com,https://www.example2.com,http://localhost:1313"In each site’s extend-head.html (or whatever custom head file your theme uses):
<meta http-equiv="Content-Security-Policy"
content="
default-src 'self';
connect-src 'self' https://your-worker-domain.com https://challenges.cloudflare.com;
script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com;
style-src 'self' 'unsafe-inline';
frame-src https://challenges.cloudflare.com;
form-action 'self' https://your-worker-domain.com;
">See params.toml example in Hugo Integration Files section.
If you host two Hugo sites (sharing one Worker):
Site A (example.com)
[contactForm]
workerBase = "https://example.com"
proxyAction = "/api/contact"
proxyNonce = "/api/form-nonce"
useTurnstile = true
turnstileSiteKey = "0x4AAAAAAE37xyprqv9kTWM8"Site B (example2.com)
[contactForm]
workerBase = "https://example2.com"
proxyAction = "/api/contact"
proxyNonce = "/api/form-nonce"
useTurnstile = true
turnstileSiteKey = "0x4AAAAAAE37xyprqv9kTWM8"Worker CORS (covers both):
[vars]
CORS_ALLOW_ORIGIN = "https://example.com,https://www.example.com,https://example2.com,https://www.example2.com"Cloudflare Routes:
example.com/api/*
www.example.com/api/*
example2.com/api/*
www.example2.com/api/*
You must tell Cloudflare which domain paths should be handled by this Worker.
Dashboard (Recommended)
- Go to: Workers & Pages → Your Worker → Triggers → Routes → Add route
- Add one route per domain:
example.com/api/*
www.example.com/api/*
example2.com/api/*
www.example2.com/api/*
Alternatively — via wrangler.toml:
routes = [
{ pattern = "example.com/api/*", zone_name = "example.com" },
{ pattern = "www.example.com/api/*", zone_name = "example.com" },
{ pattern = "example2.com/api/*", zone_name = "example2.com" },
{ pattern = "www.example2.com/api/*", zone_name = "example2.com" }
]
Use either the Cloudflare Dashboard or routes in wrangler.toml — not both.
Every domain should route these paths to your Worker:
| Method | Path | Purpose |
|---|---|---|
| GET | /api/health | Health check |
| GET | /api/form-nonce | Get one-time nonce |
| POST | /api/contact | Validate nonce + Turnstile + forward to Google Forms |
Example:
curl -i https://example.com/api/form-nonce
curl -i -X POST https://example.com/api/contactgit clone https://github.com/sunpech/cloudflare-contact-proxy.git
cd cloudflare-contact-proxy
npm installCopy the example file:
cp wrangler.example.toml wrangler.toml
npx wrangler kv:namespace create NONCE
npx wrangler kv:namespace create RATECopy the IDs output from these commands into wrangler.toml:
[[kv_namespaces]]
binding = "RATE"
id = "xxxxxx"
[[kv_namespaces]]
binding = "NONCE"
id = "xxxxxx"
In wrangler.toml, set the following:
[vars]
# Google Form endpoint (your form’s "formResponse" URL)
GFORM_ACTION = "https://docs.google.com/forms/u/0/d/e/1FAIpQLSXXXXXXXXXXXXXXX/formResponse"
# Map your Google Form fields to internal names.
GFORM_MAP = "{\"name\":\"entry.1234567890\",\"email\":\"entry.2345678901\",\"message\":\"entry.3456789012\",\"source\":\"entry.4567890123\",\"pageurl\":\"entry.5678901234\",\"useragent\":\"entry.6789012345\"}"
# Allow-listed origins
CORS_ALLOW_ORIGIN = "https://example.com,https://www.your-other-domain.com"
# Turnstile (optional, but recommended)
TURNSTILE_REQUIRED = "true"Ensure useTurnstile and turnstileSiteKey are configured in your Hugo site params (see Requirements).
💡 If testing locally, temporarily set:
CORS_ALLOW_ORIGIN = "http://localhost:1313,https://example.com"
DEV_ALLOW_BYPASS = "true"
TURNSTILE_REQUIRED = "false"npx wrangler deployIf you see:
Multiple environments are defined...
You can explicitly deploy to the default environment:
npx wrangler deploy --env=""Verify that your Worker routes are configured correctly (see API Endpoints section).
- Use hugo server → loads http://localhost:1313
- Check Worker logs:
npx wrangler tail- ✅ Nonce-based double-submit prevention
- ✅ Timing validation (minFillMs)
- ✅ Optional Turnstile CAPTCHA
- ✅ CORS whitelist enforcement
- ✅ Google Forms endpoint hidden from clients
- ✅ No persistent user data stored (KV is short-lived)
This project was created with the assistance of ChatGPT.
While functional and tested, parts of the code were generated with AI suggestions, so you may want to review it carefully before using in production or extending it.
This project is licensed under the MIT License.

