Integrating Cloudflare Turnstile with Sitecore Forms

Index
- TLDR;
- How Turnstile Works
- Ways in Which Turnstile is Superior Compared to reCAPTCHA
- Clarity on Pricing / Features
- Goals for Integrating Turnstile with Sitecore Forms
- Explanation of High Level Functionality
- Specifics Regarding Sitecore Forms Integration
- Sitecore Forms HTML Anatomy
- Cloudflare Worker
- Cloudflare Snippet
- Testing
- Future Updates
TLDR;
This post shows how you can quickly prevent form spam and to protect sensitive areas of your site using Cloudflare Turnstile, Cloudflare Snippets, and Cloudflare Workers. It's a slick solution that can be deployed very quickly. This specific implementation does make some assumptions and you will certainly want to customize it to your liking.
Here's what we're shooting for:

How Turnstile Works
Turnstile operates similarly to Google's reCAPTCHA, but the difference is that it generally doesn't rely on user interaction or invasive tracking.

When Turnstile does require interaction, the most a user needs to do is click a checkbox, as opposed to confusing and time consuming challenges:

Ways in Which Turnstile is Superior Compared to reCAPTCHA
- Does not track users across sites.
- Does not use Google cookies or store user data.
- Does not send user behavior data to Google.
- Does not introduce additional latency.
- Does not run on Google infrastructure.
- Fewer false positives.
- Challenges are easy for real users to solve.
- Essentially free for most use cases.
- Multiple widget types: managed (potentially interactive), non-interactive, and invisible.
- Can run 100% invisibly, whereas reCAPTCHA has historically had rules about sites needing to indicate that they are using reCAPTCHA. Something in the Terms of Use...
- Taps into Cloudflare's uniquely vast network intelligence, which is well known for aiding in bot and AI detection.
Clarity on Pricing / Features
- On the free plan, invisible widgets get up to 1 million siteverify (server-side) validation requests per month. The managed and non-interactive widgets have unlimited siteverify requests. This was inferred from the general availability blog post.
- You don't need an Enterprise plan to use invisible widgets.
- Cloudflare says that that white label branding is only available on Enterprise plans. What they are referring to specifically are visible widgets without the Cloudflare logo. That holds true when you are displaying widgets, but you can also hide the widgets with CSS or use invisible widgets.

Goals for Integrating Turnstile with Sitecore Forms
If we expect that clients will eventually end up using XM Cloud, we should aim for loose coupling between Turnstile and Sitecore Forms. These would be the requirements:
- Manage all Turnstile functionality at the edge, with no Sitecore changes required whatsoever.
- Automatically inject the widget on all pages containing Sitecore Forms.
- Automatically perform siteverify requests on all Sitecore Forms POST endpoints.
Explanation of High Level Functionality
- A Turnstile widget is configured in Cloudflare. Specify the allowed hostnames, and whether the widget is Managed (visible), Non-interactive (visible), or Invisible.
- Use a Cloudflare Snippet to inject the Turnstile widget into every Sitecore form on every page.
- When the page loads, the injected widget completes a challenge and receives a token from Cloudflare. The token value is automatically stored as a hidden field within the form.
- If the widget can't complete the challenge, display a message to the user and allow them to interact with the widget.
- When the form is submitted, a Worker intercepts requests to the
/formbuilderendpoint.- If the method is POST, the token is extracted from the form input, and a siteverify request is made.
- If the verification is successful, the form proceeds to submit.
- If unsuccessful, the server returns an error response. Display the error on the client side.
Specifics Regarding Sitecore Forms Integration
There are a number of ways to build Sitecore Forms. Imagine a "Contact Us" form that has two pages. The first page contains the form fields, and the second page displays a "Thank You" message. That is, the submit action on page one is to:
- Save or email the form data
- Display the "Thank You" page
In this scenario, when you submit on the first page, the server responds with pure HTML that replaces the initial <form> entirely. The same is also true if server-side validation fails; the form is injected back into itself, with additional error-related elements returned by the server.
Happy path implementation of Turnstile is very straightforward. It is the sad paths that pose the greatest challenges; particularly when the siteverify fails.
Sitecore Forms HTML Anatomy
The HTML below is sample output of a form. Notable aspects that are relevant to a Turnstile integration are:
- The form uses several AJAX-related attributes (
data-ajax="true",data-ajax-method="Post",data-ajax-update, anddata-ajax-mode="replace-with") to submit data asynchronously. This allows parts of the page to update without a full refresh. - The
data-ajax-successattribute contains inline JavaScript that runs after a successful AJAX request. This code re-initializes client-side validation and additional Sitecore-related trackers and condition parsers, ensuring that interactive behaviors are preserved on content update. - The inline AJAX success callback includes a call to
jQuery.validator.unobtrusive.parse(...). This re-binds validation to updated form elements after partial page updates, ensuring that client-side rules (such as required field checks) continue to function. - Each form field includes elements with attributes such as
data-valmsg-forand classes such asfield-validation-valid. These elements serve as placeholders where validation error messages can be rendered dynamically. - If the form fails client-side validation (
invalid-form.validateevent), the button's disabled state is removed, allowing users to correct errors and resubmit.
In summary, the aspects we care about are how the form is validated, how it changes when submitted, and how error messages are displayed.
<form action="/formbuilder?sc_site=acme&fxb.FormItemId=32a30746-f368-4731-3be1-b7101bb661ae&fxb.HtmlPrefix=fxb.00000000-0000-0000-0000-000000000000" data-ajax="true" data-ajax-method="Post" data-ajax-mode="replace-with" data-ajax-success="jQuery.validator.unobtrusive.parse('#fxb_00000000-0000-0000-0000-000000000000_31a30646-f369-4731-8be1-b9101bb961ae');jQuery.fxbFormTracker.texts=jQuery.fxbFormTracker.texts||{};jQuery.fxbFormTracker.texts.expiredWebSession='Your session has expired. Please refresh this page.';jQuery.fxbFormTracker.parse('#fxb_00000000-0000-0000-0000-000000000000_31a30646-f369-4731-8be1-b9101bb961ae');jQuery.fxbConditions.parse('#fxb_00000000-0000-0000-0000-000000000000_31a30646-f369-4731-8be1-b9101bb961ae')" data-ajax-update="#fxb_00000000-0000-0000-0000-000000000000_31a30646-f369-4731-8be1-b9101bb961ae" data-sc-fxb="31a30646-f369-4731-8be1-b9101bb961ae" enctype="multipart/form-data" id="fxb_00000000-0000-0000-0000-000000000000_31a30646-f369-4731-8be1-b9101bb961ae" method="post"> <input id="fxb_00000000-0000-0000-0000-000000000000_FormSessionId" name="fxb.00000000-0000-0000-0000-000000000000.FormSessionId" type="hidden" value="ff25a83a-f4aa-4792-a230-9886eed6cbf5"/> <input id="fxb_00000000-0000-0000-0000-000000000000_IsSessionExpired" name="fxb.00000000-0000-0000-0000-000000000000.IsSessionExpired" type="hidden" value="0"/> <input name="__RequestVerificationToken" type="hidden" value=""/> <input id="fxb_00000000-0000-0000-0000-000000000000_FormItemId" name="fxb.00000000-0000-0000-0000-000000000000.FormItemId" type="hidden" value="31a30646-f369-4731-8be1-b9101bb961ae"/> <input type="hidden" data-sc-fxb-condition value='{}'/> <input id="fxb_00000000-0000-0000-0000-000000000000_PageItemId" name="fxb.00000000-0000-0000-0000-000000000000.PageItemId" type="hidden" value="c34c6336-9b55-40f6-97f3-0635e5d188e2"/> <div data-sc-field-key="E08EBD172B2442D1AEFBB13F2F1AC71A" class="form-box wysiwyg-content"> <input id="fxb_00000000-0000-0000-0000-000000000000_Fields_Index_a86707b6-5c5d-43b6-a715-dbbd58b428a1" name="fxb.00000000-0000-0000-0000-000000000000.Fields.Index" type="hidden" value="a86707b6-5c5d-43b6-a715-dbbd58b428a1"/> <input id="fxb_00000000-0000-0000-0000-000000000000_Fields_a86707b6-5c5d-43b6-a715-dbbd58b428a1__ItemId" name="fxb.00000000-0000-0000-0000-000000000000.Fields[a86707b6-5c5d-43b6-a715-dbbd58b428a1].ItemId" type="hidden" value="a86707b6-5c5d-43b6-a715-dbbd58b428a1"/> <span class="field-validation-valid" data-valmsg-for="fxb.00000000-0000-0000-0000-000000000000.Fields[a86707b6-5c5d-43b6-a715-dbbd58b428a1].Value" data-valmsg-replace="true"></span> <div data-sc-field-key="2673A756658E418291E78F1F519906B2" class="field-group"> <input id="fxb_00000000-0000-0000-0000-000000000000_Fields_Index_ecf7d9a2-8d5e-440b-ab71-ab6960cb68ad" name="fxb.00000000-0000-0000-0000-000000000000.Fields.Index" type="hidden" value="ecf7d9a2-8d5e-440b-ab71-ab6960cb68ad"/> <input id="fxb_00000000-0000-0000-0000-000000000000_Fields_ecf7d9a2-8d5e-440b-ab71-ab6960cb68ad__ItemId" name="fxb.00000000-0000-0000-0000-000000000000.Fields[ecf7d9a2-8d5e-440b-ab71-ab6960cb68ad].ItemId" type="hidden" value="ecf7d9a2-8d5e-440b-ab71-ab6960cb68ad"/> <span class="field-validation-valid" data-valmsg-for="fxb.00000000-0000-0000-0000-000000000000.Fields[ecf7d9a2-8d5e-440b-ab71-ab6960cb68ad].Value" data-valmsg-replace="true"></span> <div data-sc-field-key="E301B56590C946B5BE03A98E6ABDE0A5" class="field-container -tablet-col-2"> <input id="fxb_00000000-0000-0000-0000-000000000000_Fields_Index_44026876-fa95-44b9-98a7-fcf68cf18e3c" name="fxb.00000000-0000-0000-0000-000000000000.Fields.Index" type="hidden" value="44026876-fa95-44b9-98a7-fcf68cf18e3c"/> <input id="fxb_00000000-0000-0000-0000-000000000000_Fields_44026876-fa95-44b9-98a7-fcf68cf18e3c__ItemId" name="fxb.00000000-0000-0000-0000-000000000000.Fields[44026876-fa95-44b9-98a7-fcf68cf18e3c].ItemId" type="hidden" value="44026876-fa95-44b9-98a7-fcf68cf18e3c"/> <label for="fxb_00000000-0000-0000-0000-000000000000_Fields_44026876-fa95-44b9-98a7-fcf68cf18e3c__Value" class="">First Name</label> <input id="fxb_00000000-0000-0000-0000-000000000000_Fields_44026876-fa95-44b9-98a7-fcf68cf18e3c__Value" name="fxb.00000000-0000-0000-0000-000000000000.Fields[44026876-fa95-44b9-98a7-fcf68cf18e3c].Value" class="" type="text" value="" maxlength="255" placeholder="" data-sc-tracking="True" data-sc-field-name="First Name" data-sc-field-key="6A034B47259641A8BE69DBEC9EE0573F" data-val-required="First Name is required." data-val="true"/> <span class="field-validation-valid" data-valmsg-for="fxb.00000000-0000-0000-0000-000000000000.Fields[44026876-fa95-44b9-98a7-fcf68cf18e3c].Value" data-valmsg-replace="true"></span> </div> <div data-sc-field-key="DE40BAEF318B4D8F80671B4346AD4B07" class="field-container -tablet-col-2"> <input id="fxb_00000000-0000-0000-0000-000000000000_Fields_Index_1b618b13-6d1f-4287-982e-dbab07957c30" name="fxb.00000000-0000-0000-0000-000000000000.Fields.Index" type="hidden" value="1b618b13-6d1f-4287-982e-dbab07957c30"/> <input id="fxb_00000000-0000-0000-0000-000000000000_Fields_1b618b13-6d1f-4287-982e-dbab07957c30__ItemId" name="fxb.00000000-0000-0000-0000-000000000000.Fields[1b618b13-6d1f-4287-982e-dbab07957c30].ItemId" type="hidden" value="1b618b13-6d1f-4287-982e-dbab07957c30"/> <label for="fxb_00000000-0000-0000-0000-000000000000_Fields_1b618b13-6d1f-4287-982e-dbab07957c30__Value" class="">Last Name</label> <input id="fxb_00000000-0000-0000-0000-000000000000_Fields_1b618b13-6d1f-4287-982e-dbab07957c30__Value" name="fxb.00000000-0000-0000-0000-000000000000.Fields[1b618b13-6d1f-4287-982e-dbab07957c30].Value" class="" type="text" value="" maxlength="255" placeholder="" data-sc-tracking="True" data-sc-field-name="Last Name" data-sc-field-key="F17A0F02F1B24C85993D6C45F94A2CB6" data-val-required="Last Name is required." data-val="true"/> <span class="field-validation-valid" data-valmsg-for="fxb.00000000-0000-0000-0000-000000000000.Fields[1b618b13-6d1f-4287-982e-dbab07957c30].Value" data-valmsg-replace="true"></span> </div> </div> <div data-sc-field-key="28E0063679594F69B182CF2336D81486" class="field-group"> <div data-sc-field-key="C1283CB9C61041789E967A287398AD86" class="field-container -tablet-col-2"> <input id="fxb_00000000-0000-0000-0000-000000000000_Fields_Index_6ea0f7cc-ab84-4639-a6f5-120d3c5f4cff" name="fxb.00000000-0000-0000-0000-000000000000.Fields.Index" type="hidden" value="6ea0f7cc-ab84-4639-a6f5-120d3c5f4cff"/> <input id="fxb_00000000-0000-0000-0000-000000000000_Fields_6ea0f7cc-ab84-4639-a6f5-120d3c5f4cff__ItemId" name="fxb.00000000-0000-0000-0000-000000000000.Fields[6ea0f7cc-ab84-4639-a6f5-120d3c5f4cff].ItemId" type="hidden" value="6ea0f7cc-ab84-4639-a6f5-120d3c5f4cff"/> <label for="fxb_00000000-0000-0000-0000-000000000000_Fields_6ea0f7cc-ab84-4639-a6f5-120d3c5f4cff__Value" class="">Email</label> <input id="fxb_00000000-0000-0000-0000-000000000000_Fields_6ea0f7cc-ab84-4639-a6f5-120d3c5f4cff__Value" name="fxb.00000000-0000-0000-0000-000000000000.Fields[6ea0f7cc-ab84-4639-a6f5-120d3c5f4cff].Value" class="" type="email" value="" maxlength="255" placeholder="" data-sc-tracking="True" data-sc-field-name="Email" data-sc-field-key="C0D5CA9E24E74B77A95A59ECF45FAA84" data-val-regex="Email contains an invalid email address." data-val-regex-pattern="^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,17}$" data-val-required="Email is required." data-val="true"/> <span class="field-validation-valid" data-valmsg-for="fxb.00000000-0000-0000-0000-000000000000.Fields[6ea0f7cc-ab84-4639-a6f5-120d3c5f4cff].Value" data-valmsg-replace="true"></span> </div> </div> <div data-sc-field-key="9BD1C73AC2434C26963318B142BE9DE5" class="field-container"> <label for="fxb_00000000-0000-0000-0000-000000000000_Fields_4684706c-25db-483a-b807-6f005332f580__Value" class="">Questions/Comments</label> <textarea id="fxb_00000000-0000-0000-0000-000000000000_Fields_4684706c-25db-483a-b807-6f005332f580__Value" name="fxb.00000000-0000-0000-0000-000000000000.Fields[4684706c-25db-483a-b807-6f005332f580].Value" class="" rows="4" maxlength="512" placeholder="" data-sc-tracking="True" data-sc-field-name="Questions Comments" data-sc-field-key="B619658202DC49248625CC9AE3FDFC85"></textarea> <span class="field-validation-valid" data-valmsg-for="fxb.00000000-0000-0000-0000-000000000000.Fields[4684706c-25db-483a-b807-6f005332f580].Value" data-valmsg-replace="true"></span> </div> <input value="SUBMIT" type="submit" class=" sitecoreformbutton" name="fxb.00000000-0000-0000-0000-000000000000.e661c967-1a53-45b6-9fab-39ae73ae053e"/> <input id="fxb_00000000-0000-0000-0000-000000000000_NavigationButtons" name="fxb.00000000-0000-0000-0000-000000000000.NavigationButtons" type="hidden" value="e661c967-1a53-45b6-9fab-39ae73ae053e"/> <input id="fxb_00000000-0000-0000-0000-000000000000_e661c967-1a53-45b6-9fab-39ae73ae053e" name="fxb.00000000-0000-0000-0000-000000000000.e661c967-1a53-45b6-9fab-39ae73ae053e" type="hidden" value="1"/> </div></form>Cloudflare Worker
The Worker proxies Sitecore form submission requests and makes siteverify requests.
export default { async fetch(request, env) { const url = new URL(request.url)
if (request.method === 'POST' && url.pathname == '/formbuilder') { console.log(url.pathname) return handlePost(request, env); }
return await fetch(request); },};
async function handlePost(request, env) { // DOCS https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
// Test secrets https://developers.cloudflare.com/turnstile/troubleshooting/testing/#dummy-sitekeys-and-secret-keys // 1x0000000000000000000000000000000AA Always passes // 2x0000000000000000000000000000000AA Always fails // 3x0000000000000000000000000000000AA Yields a "token already spent" error // Dummy secret keys will only accept the XXXX.DUMMY.TOKEN.XXXX dummy response token.
const formRequest = request.clone(); const formBody = await formRequest.formData();
// There may be more than one token in the form data const tokens = formBody.getAll('cf-turnstile-response'); const ip = request.headers.get('CF-Connecting-IP');
let tokenValidated = false;
for (const token of tokens) {
if (!token?.trim().length) continue; // skip empty or blank tokens
const siteVerifyData = new FormData();
siteVerifyData.append('secret', env.TURNSTILE_SECRET_KEY); siteVerifyData.append('response', token); siteVerifyData.append('remoteip', ip);
const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', body: siteVerifyData });
const outcome = await result.json();
if (outcome.success) { tokenValidated = true; break; } else { // TODO: could log failure reasons if needed https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes } }
if (!tokenValidated) { return new Response( 'A validation error occurred. Please complete the validation and submit again. If the issue persists, contact us via phone or email.', { status: 400, headers: { 'Content-Type': 'text/html' } }); }
// Validation passed -- continue with the POST to Sitecore Forms const clonedRequest = request.clone();
const newRequest = new Request(clonedRequest, { body: clonedRequest.body, method: request.method, headers: request.headers, });
let formResponse = await fetch(newRequest); let responseText = await formResponse.text();
return new Response(responseText, { status: formResponse.status, statusText: formResponse.statusText, headers: formResponse.headers, });}Cloudflare Snippet
The Snippet injects the Turnstile script reference, injects widgets, and responds to various states.
// DEVELOPER DOCS https://developers.cloudflare.com/turnstile/get-started/export default { async fetch(request) { const response = await fetch(request); const contentType = response.headers.get("Content-Type");
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/ // 1x00000000000000000000AA Always passes visible // 2x00000000000000000000AB Always blocks visible // 1x00000000000000000000BB Always passes invisible // 2x00000000000000000000BB Always blocks invisible // 3x00000000000000000000FF Forces an interactive challenge visible // These dummy sitekeys will produce the XXXX.DUMMY.TOKEN.XXXX dummy response token. // Production secret keys will reject this token. You must also use a dummy secret key for testing purposes.
const TURNSTILE_SITE_ID = "CHANGE_ME";
if (!contentType || !contentType.includes("text/html")) { return response; }
const scriptToInject = ` <script> document.addEventListener("DOMContentLoaded", function() { const SITECORE_FORMS_SELECTOR = 'form[id^="fxb"]';
const ERROR_MESSAGE_CONTAINER_STYLE = 'display:block;max-width:150px;'; const ERROR_MESSAGE_CONTAINER_SELECTOR_CLASS = 'turnstile-error-message';
const VISIBLE_ERROR_CONTAINER_CLASSES = 'error-message field-validation-error ' + ERROR_MESSAGE_CONTAINER_SELECTOR_CLASS; const VISIBLE_WIDGET_CONTAINER_STYLES = 'display:block;margin-bottom:25px;';
// If the invisible widget must be rendered (rare cases in which interaction is required), do so in a compact fashion to ensure mobile friendliness const TURNSTILE_NORMAL_SIZE_MIN_PX = 429; const TURNSTILE_SIZE_SETTING = window.innerWidth >= TURNSTILE_NORMAL_SIZE_MIN_PX ? 'normal' : 'compact';
const ERROR_MESSAGE = 'Please complete the verification and submit the form again. If the issue persists, contact us via phone or email.';
if (!document.querySelectorAll(SITECORE_FORMS_SELECTOR).length) return;
window.attachGlobalSitecoreFormObserver = function() { const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.removedNodes.forEach(node => { if ( node.nodeType === 1 && node.tagName === 'FORM' && node.id && node.id.startsWith('fxb_') ) { console.debug('A Sitecore form was removed:', node); } });
// Added forms mutation.addedNodes.forEach(node => { // If the new node *is* a form if ( node.nodeType === 1 && node.tagName === 'FORM' && node.id && node.id.startsWith('fxb_') ) { console.debug('A new Sitecore form was added:', node); window.injectTurnstileWidget(node); window.bindAjaxHandlers(node); } // If the node *contains* new forms else if (node.nodeType === 1) { const forms = node.querySelectorAll('form[id^="fxb_"]'); forms.forEach(form => { console.log('A new Sitecore form was found inside a container:', form); window.injectTurnstileWidget(form); window.bindAjaxHandlers(form); }); } }); }); });
// Observe entire <body> for scenarios in which forms may be added or replaced for any reason observer.observe(document.body, { childList: true, subtree: true }); };
attachGlobalSitecoreFormObserver();
window.bindAjaxHandlers = function(sitecoreForm) { // Inject form failure handling for when siteverify fails. Append rather than replace in order not to blow away existing functionality. const existingFailureHandler = (sitecoreForm.getAttribute("data-ajax-failure") || "").trim(); let needsSemicolon = existingFailureHandler && !existingFailureHandler.endsWith(";"); let separator = existingFailureHandler ? " " : ""; let newHandler = "window.handleFormFailure(arguments, this);"; sitecoreForm.setAttribute( "data-ajax-failure", (needsSemicolon ? existingFailureHandler + ";" : existingFailureHandler) + separator + newHandler ); };
window.turnstileCallback = function(token) { console.debug('Turnstile challenge returned the token / value:', token); };
window.displayTurnstileErrorMessage = function(sitecoreFormElement) { const submitButton = sitecoreFormElement.querySelector('input[type="submit"], button[type="submit"]'); if (!submitButton){ // Can occur when displaying a second form page that does not have a submit button // There may be a scenario in which this needs to be handled instead of returning early console.debug("No submit button found -- will not display widget"); return; }
const existingErrorMessage = sitecoreFormElement.querySelector('.' + ERROR_MESSAGE_CONTAINER_SELECTOR_CLASS); if (!existingErrorMessage) { const errorDiv = document.createElement("div");
errorDiv.className = VISIBLE_ERROR_CONTAINER_CLASSES; errorDiv.innerText = ERROR_MESSAGE; errorDiv.style = ERROR_MESSAGE_CONTAINER_STYLE;
submitButton.parentNode.insertBefore(errorDiv, submitButton); } };
// Invoked when there is an error (e.g. network error or the challenge failed). // If an error callback returns with a non-falsy result, Turnstile will assume that the error callback handled the error accordingly. window.turnstileErrorCallback = function(turnstileWidgetElement, errorCode) { console.warn('An error occurred while fetching Turnstile token.', { turnstileWidgetElement }, { errorCode });
// Display the widget so that the user can see the error turnstileWidgetElement.style = VISIBLE_WIDGET_CONTAINER_STYLES;
// Display error message calling for user to perform the verification window.displayTurnstileErrorMessage(turnstileWidgetElement.closest('form'))
return false; };
window.turnstileBeforeInteractiveCallback = function(turnstileWidgetElement) { // Display the widget so that the user can interact with it turnstileWidgetElement.style = VISIBLE_WIDGET_CONTAINER_STYLES; };
window.injectTurnstileWidget = function(sitecoreFormElement) { const submitButton = sitecoreFormElement.querySelector('input[type="submit"], button[type="submit"]'); if (!submitButton){ // This can happen when the form is successfully submitted and is now displaying a thank you page console.warn('Unable to locate submit button -- will not inject turnstile widget'); return; }
const existingTurnstileWidget = sitecoreFormElement.querySelector('.turnstile-widget'); if (existingTurnstileWidget){ console.warn('Turnstile elements already present -- will not inject'); return; }
const turnstileDiv = document.createElement("div"); turnstileDiv.className = sitecoreFormElement.id + "-turnstile turnstile-widget"; turnstileDiv.setAttribute("style", "display:none;");
submitButton.parentNode.insertBefore(turnstileDiv, submitButton);
let cdata = sitecoreFormElement.id // Replace any characters that are NOT letters, numbers, underscores, or hyphens with a hyphen .replace(/[^a-zA-Z0-9_-]/g, '-') // Limit to 255 chars to comply with the Turnstile cData limit .substring(0, 255);
const widgetId = turnstile.render(turnstileDiv, { // https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations sitekey: "${TURNSTILE_SITE_ID}", callback: (token) => window.turnstileCallback(token), 'error-callback': (errorCode) => window.turnstileErrorCallback(turnstileDiv, errorCode), 'before-interactive-callback': () => window.turnstileBeforeInteractiveCallback(turnstileDiv), 'size': TURNSTILE_SIZE_SETTING, 'cData': cdata, 'theme': 'light' });
if (widgetId == undefined) { // TODO: handle unsuccessful invocation of turnstile render console.error("Failed to render turnstile on", { turnstileDiv } ) } else { console.debug("Rendered turnstile", { widgetId }); } };
// Called when the form receives a non 200 response after submitting window.handleFormFailure = function (args, contextElement) { console.debug("Form submission failed", {args}, {contextElement});
const errorCode = args?.status; const responseText = args?.responseText;
// TODO: consider case when status is something other than 400; particularly 500 errors if (args?.status == "400" || args?.status == 400){ console.debug('Error response code', errorCode); console.debug('Response text', responseText); }
const form = contextElement?.closest?.('form'); if (!form) { console.warn("Could not locate form from context element."); return; }
const submitButton = form.querySelector('input[type="submit"], button[type="submit"]'); if (!submitButton){ // Can occur when displaying a second form page that does not have a submit button // There may be a scenario in which this needs to be handled instead of returning early console.debug("No submit button found -- will not display widget"); return; }
// Display the interactive widget either via window.injectTurnstileWidget(form); or by displaying the hidden managed widget const widget = form.querySelector('.turnstile-widget'); if (widget){ turnstile.reset(widget); widget.style = VISIBLE_WIDGET_CONTAINER_STYLES; } else { // TODO: handle console.error('No turnstile present in the form'); }
window.displayTurnstileErrorMessage(form);
// Re-enable the button and change text back to "Submit" submitButton.disabled = false;
if (submitButton.tagName === 'INPUT') { submitButton.value = "Submit"; } else { submitButton.innerText = "Submit"; }
};
document.querySelectorAll(SITECORE_FORMS_SELECTOR)?.forEach(sitecoreForm => { window.injectTurnstileWidget(sitecoreForm); window.bindAjaxHandlers(sitecoreForm); }); }); </script> `;
return new HTMLRewriter() .on("head", { element(element) { element.append( // https://developers.cloudflare.com/turnstile/tutorials/implicit-vs-explicit-rendering // For explicit rendering use https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onloadTurnstileCallback `<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>`, { html: true } ); }, }) .on("body", { element(element) { element.append(scriptToInject, { html: true }); }, }) .transform(response); },};Testing
As stated earlier, the happy paths are easy to implement and easy to test. The sad paths are trickier.
Cloudflare Turnstile provides various test keys that can simulate various widget and siteverify states / responses: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
See my previous post on how to disable client-side validation to speed up testing.
Future Updates
- Look into enabling pre-clearance.
- Code refactoring.
- Potentially display error states / messages in a popup so as to not make assumptions about the various styles and structures of forms that this functionality may be enabled on.
Challenge yourself,
MG





