Stop form spam in Hugo
Updated
This guide adds Bot403 to a contact form on a Hugo site. Hugo builds only static files. Thus a different program must check the token. In this guide, a Cloudflare Worker serves the Hugo output and gets the POST from the form. The visitor sees no puzzle, and the script sets no cookie.
The example uses Hugo 0.168 and Wrangler 4.152. The example was run on 23 October 2026 with wrangler dev against a local copy of the verify API. Four cases were tested: a good token, a filled honeypot, a capped month and an API that does not answer.
Before you start
Sign in. Then create a site. Copy its public key (pk_) and its secret key (sk_). Put the secret key in the Worker, never in the Hugo site:
npm install --save-dev wrangler
echo "BOT403_SECRET_KEY=sk_live_YOUR_SECRET_KEY" > .dev.vars # local only, keep it out of git
npx wrangler secret put BOT403_SECRET_KEY # production1. The script in the base layout
Put the script tag in the base layout. Then every page has the script. The script protects every form on the page. Before the browser sends a form, the script adds a hidden _bot403 field with the token.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ .Title }}</title>
<script src="https://bot403.com/v1/bot403.js" data-bot403-key="pk_live_YOUR_PUBLIC_KEY" async></script>
</head>
<body>
{{ block "main" . }}{{ end }}
</body>
</html>2. The form as a shortcode
<form method="POST" action="/api/contact">
<label>Email <input name="email" type="email" required></label>
<label>Message <textarea name="message" required></textarea></label>
<button type="submit">Send</button>
</form>---
title: Contact
---
Write to us.
{{< contact-form >}}3. The Worker
Cloudflare serves the files in public/. For every other path, Cloudflare runs the Worker. The Worker sends the form fields to Bot403. It accepts the message only if the answer is ok: true. If Bot403 does not answer in 5 seconds, the Worker accepts the form and logs the error.
{
"name": "my-hugo-site",
"main": "worker/index.js",
"compatibility_date": "2026-10-01",
// Hugo writes the site to public/. Cloudflare serves those files and
// runs the worker for every path that is not a file, such as /api/contact
"assets": { "directory": "./public" }
}export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname !== "/api/contact" || request.method !== "POST") {
return new Response("Not found", { status: 404 });
}
const form = await request.formData();
// 1. Post the whole form. Bot403 reads the _bot403 field and the honeypot field
const body = new URLSearchParams();
for (const [name, value] of form) if (typeof value === "string") body.append(name, value);
let result = { ok: true, reason: "" };
try {
const res = await fetch("https://bot403.com/api/v1/verify", {
method: "POST",
headers: { Authorization: `Bearer ${env.BOT403_SECRET_KEY}` },
body,
signal: AbortSignal.timeout(5000),
});
result = await res.json();
} catch (err) {
console.error("bot403 verify failed, letting the form through", err);
}
// 2. Refuse when ok is false, except for reason "quota"
if (!result.ok && result.reason !== "quota") {
return new Response("Try again", { status: 400 });
}
// 3. A real visitor: send the email or store the message here
return Response.redirect(new URL("/thanks/", request.url), 303);
},
};If you use Netlify or Vercel, put the same request handling in a serverless function. Then set the form action to the URL of this function. Only the Cloudflare setup was tested for this guide.
The trap: Hugo removes raw HTML in Markdown
The fast method is to paste the form, or the script tag, into content/contact.md. But by default, the Hugo Markdown renderer removes raw HTML. The page builds without an error, but the HTML is not in the output. The output has only this comment:
---
title: Contact
---
<form method="POST" action="/api/contact">…</form><!-- raw HTML omitted -->
For this reason, this guide puts the script in a layout and the form in a shortcode. You can also set markup.goldmark.renderer.unsafe = true. But then Hugo puts all raw HTML from all content files into the output, not only your form.
A second trap: hugo server serves only the static files. The form posts to /api/contact, and only the Worker has this path. Thus, test with wrangler dev. It serves the static files and the Worker.
Test it
hugo && npx wrangler dev
# in a second terminal: a form without a token
curl -i -d "[email protected]&message=hi" http://localhost:8787/api/contact
# HTTP/1.1 400 Bad Request
# Try againThen open http://localhost:8787/contact/. Send the form. The browser goes to /thanks/, and the site in your dashboard shows one check. If you get a failure that you do not expect, read the troubleshooting page. It lists every reason and its fix.