Protect every form with Turnstile
-
What you'll see
A site has a contact form, a booking form, a newsletter signup — anything that accepts input from an anonymous visitor and turns it into an email, a stored document or an outbound notification. Without bot protection it will be found and abused, usually within weeks of launch. The mailbox fills with junk, the customer stops reading it, and a real enquiry gets lost.
-
What's actually happening
Every form in Docly is protected with Cloudflare Turnstile. Not the ones that look attractive to spammers — all of them. A form without it is not finished.
Use the built-in form submission. Do not write your own endpoint.
Docly submits and mails forms for you. Two attributes on the
<form>do the whole job:- Docly sends every submission by email to the workspace owner. The optional
data-smtp="#/MySmtpConfig"points to an “SMTP configuration” document and only chooses which SMTP server sends it — it is not the recipient. An email address indata-smtpmakes every submission fail with “Send failed due to configuration error”. data-validate="Turnstile"— Docly calls#/API/Turnstile.jsserver-side before accepting the form. Throw to reject, returntrueto accept.
This is the mechanism. Writing a custom API endpoint that reads
form, verifies the token and callsdocly.sendForm()reimplements it by hand, badly: you own the validation ordering, the redirects and the error paths, and you will get one of them wrong.sendForm()exists for the case where you are already inside an API function for another reason — it is not the route for an ordinary contact form.The validation function is the only code you write
It lives at
#/API/<Name>.js, matching the value ofdata-validate. It reads the token fromform["cf-turnstile-response"], posts it to Cloudflare withdocly.httpFormPost— the endpoint expects URL-encoded, not JSON — and throws on failure.Keys, and the trap in the test keys
The site key is public and belongs in the markup. The secret key is not, and belongs in
#/Config, the standard place for configuration, which is never served over HTTP — see Put configuration and secrets in the Config document.Cloudflare publishes test keys that always pass. A form running on the always-pass secret looks protected, tests green, and blocks nothing. Make the validation function fail closed while a test key is in place — throw rather than accept. Otherwise your own test submissions reach the customer''s mailbox, because the success path mails a real recipient.
Never store submissions in the file tree
A form submission is a message, not content. The built-in submission mails it. Files written into the workspace — even under
#/— accumulate, hold personal data, and nothing prunes them.What this costs in the Content-Security-Policy
Turnstile loads a script and runs in an iframe from
challenges.cloudflare.com, so that host has to be allowed. This does not cost the A+ rating: the policy still has nounsafe-inlineand nounsafe-eval. Allowing one named host is not the same as opening the policy. - Docly sends every submission by email to the workspace owner. The optional
-
What to do
1. Keys in
#/ConfigConfiguration goes in the Config document, not in an ad-hoc JSON file — see Put configuration and secrets in the Config document. Two fields, the secret using
Type: "password":CF_Turnstile_Site_Key 0x4AAAAAAA... (public, goes in markup) CF_Turnstile_Secret_Key 0x4AAAAAAA... (secret, API function only)2. The form — built-in submission, no custom endpoint
#{ var cfg = docly.getJson("#/Config") || {}; }# <form id="ContactForm" data-validate="Turnstile"> <input type="text" name="Name" placeholder="Name" required> <input type="text" name="Email" placeholder="Email" required> <textarea name="Message" placeholder="Your message" required></textarea> <div class="cf-turnstile" data-sitekey="#docly.htmlAttributeEncode(cfg.CF_Turnstile_Site_Key)#"></div> <button type="submit">Send</button> </form> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>The form needs an
id— Docly finds the form by it when the submission arrives. The email subject is set by Docly from the page title and the form id. Adddata-smtp="#/MySmtpConfig"only if the mail should be sent through your own SMTP server; the recipient is always the workspace owner.The widget injects a hidden
cf-turnstile-responsefield. The script tag is external — never inline.On a page that uses a master, the script tag needs an explicit transform or the merge drops it silently:
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer xdt:Transform="Insert"></script>Without it the widget renders, no script loads, no token is produced, and the form fails validation with no visible cause. Check the served HTML for the script tag, not just the widget div.
3.
#/API/Turnstile.jsexport default function () { var cfg = docly.getJson("#/Config") || {}; // Cloudflare's test keys accept everything. Fail closed, not open. var secret = cfg.CF_Turnstile_Secret_Key; if (!secret || secret.indexOf("1x00000000") === 0) { throw new Error("Turnstile is not configured with real keys."); } var token = form["cf-turnstile-response"]; if (typeof token !== "string" && token && typeof token.length === "number") { token = token.length > 0 ? token[0] : ""; } if (!token) { throw new Error("Missing Turnstile token."); } // httpFormPost, not httpPost — siteverify expects URL-encoded, not JSON. var res = docly.httpFormPost("https://challenges.cloudflare.com/turnstile/v0/siteverify", { secret: secret, response: String(token) }); if (!res || res.success !== true) { throw new Error("Turnstile validation failed."); } return true; }4. Content-Security-Policy
In
#/site.json, add the host toscript-src,connect-srcandframe-src. Nothing else changes:"Content-Security-Policy": "default-src ''self''; script-src ''self'' https://challenges.cloudflare.com; style-src ''self''; img-src ''self'' data:; font-src ''self''; connect-src ''self'' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; form-action ''self''; frame-ancestors ''none''; base-uri ''self''; object-src ''none''; upgrade-insecure-requests"Testing without mailing the customer
The success path mails a real recipient. Test the rejection paths — missing token, invalid token, missing required field. Leave the success path until the customer is ready — a successful submission is mailed to the workspace owner, so test in a workspace you own first. Failing closed on the test key prevents the accident as well as the spam.
Checklist
- Built-in submission (form with an
id) — no hand-written submit endpoint.data-smtp, if used, names an SMTP configuration, not an address. data-validatepointing at a validation function in#/API/.- Secret under
#/, site key in markup, script tag external. - Validation throws while a test key is in place.
- Nothing written into the file tree.
challenges.cloudflare.cominscript-src,connect-src,frame-src; still nounsafe-inline.- Rejection paths tested; success path not fired at the customer.
- Built-in submission (form with an
Last reviewed 26 Aug 2026