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:
data-smtp="address@example.com"— Docly sends the submission by email. Without it the submission goes to the workspace owner instead.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 calls docly.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 of data-validate. It reads the token from form["cf-turnstile-response"], posts it to Cloudflare with docly.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 no unsafe-inline and no unsafe-eval. Allowing one named host is not the same as opening the policy.
What to do
1. Keys in #/Config
Configuration 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 data-smtp="#docly.htmlAttributeEncode(fi.Epost)#"
data-validate="Turnstile"
data-subject="Enquiry from the website">
<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 widget injects a hidden cf-turnstile-response field. 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.js
export 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 to script-src, connect-src and frame-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, or point data-smtp at your own address first. Failing closed on the test key prevents the accident as well as the spam.
Checklist
- Built-in submission via
data-smtp— no hand-written submit endpoint. 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.