Integrating Cloudflare Turnstile with Sitecore Forms

Cover Image for Integrating Cloudflare Turnstile with Sitecore Forms

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.

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.

Turnstile Architecture

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

Silly reCAPTCHA challenges

Ways in Which Turnstile is Superior Compared to reCAPTCHA

  1. Does not track users across sites.
  2. Does not use Google cookies or store user data.
  3. Does not send user behavior data to Google.
  4. Does not introduce additional latency.
  5. Does not run on Google infrastructure.
  6. Fewer false positives.
  7. Challenges are easy for real users to solve.
  8. Essentially free for most use cases.
  9. Multiple widget types: managed (potentially interactive), non-interactive, and invisible.
  10. 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...

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.

Turnstile Pricing

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 /formbuilder endpoint.
    • 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:

  1. Save or email the form data
  2. 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, and data-ajax-mode="replace-with") to submit data asynchronously. This allows parts of the page to update without a full refresh.
  • The data-ajax-success attribute 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-for and classes such as field-validation-valid. These elements serve as placeholders where validation error messages can be rendered dynamically.
  • If the form fails client-side validation (invalid-form.validate event), 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.


_54
<form action="/formbuilder?sc_site=acme&amp;fxb.FormItemId=32a30746-f368-4731-3be1-b7101bb661ae&amp;fxb.HtmlPrefix=fxb.00000000-0000-0000-0000-000000000000" data-ajax="true" data-ajax-method="Post" data-ajax-mode="replace-with" data-ajax-success="
_54
jQuery.validator.unobtrusive.parse(&#39;#fxb_00000000-0000-0000-0000-000000000000_31a30646-f369-4731-8be1-b9101bb961ae&#39;);
_54
jQuery.fxbFormTracker.texts=jQuery.fxbFormTracker.texts||{};
_54
jQuery.fxbFormTracker.texts.expiredWebSession=&#39;Your session has expired. Please refresh this page.&#39;;
_54
jQuery.fxbFormTracker.parse(&#39;#fxb_00000000-0000-0000-0000-000000000000_31a30646-f369-4731-8be1-b9101bb961ae&#39;);
_54
jQuery.fxbConditions.parse(&#39;#fxb_00000000-0000-0000-0000-000000000000_31a30646-f369-4731-8be1-b9101bb961ae&#39;)" 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">
_54
<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"/>
_54
<input id="fxb_00000000-0000-0000-0000-000000000000_IsSessionExpired" name="fxb.00000000-0000-0000-0000-000000000000.IsSessionExpired" type="hidden" value="0"/>
_54
<input name="__RequestVerificationToken" type="hidden" value=""/>
_54
<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"/>
_54
<input type="hidden" data-sc-fxb-condition value='{}'/>
_54
<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"/>
_54
<div data-sc-field-key="E08EBD172B2442D1AEFBB13F2F1AC71A" class="form-box wysiwyg-content">
_54
<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"/>
_54
<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"/>
_54
<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>
_54
<div data-sc-field-key="2673A756658E418291E78F1F519906B2" class="field-group">
_54
<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"/>
_54
<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"/>
_54
<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>
_54
<div data-sc-field-key="E301B56590C946B5BE03A98E6ABDE0A5" class="field-container -tablet-col-2">
_54
<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"/>
_54
<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"/>
_54
<label for="fxb_00000000-0000-0000-0000-000000000000_Fields_44026876-fa95-44b9-98a7-fcf68cf18e3c__Value" class="">First Name</label>
_54
<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"/>
_54
<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>
_54
</div>
_54
<div data-sc-field-key="DE40BAEF318B4D8F80671B4346AD4B07" class="field-container -tablet-col-2">
_54
<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"/>
_54
<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"/>
_54
<label for="fxb_00000000-0000-0000-0000-000000000000_Fields_1b618b13-6d1f-4287-982e-dbab07957c30__Value" class="">Last Name</label>
_54
<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"/>
_54
<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>
_54
</div>
_54
</div>
_54
<div data-sc-field-key="28E0063679594F69B182CF2336D81486" class="field-group">
_54
<div data-sc-field-key="C1283CB9C61041789E967A287398AD86" class="field-container -tablet-col-2">
_54
<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"/>
_54
<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"/>
_54
<label for="fxb_00000000-0000-0000-0000-000000000000_Fields_6ea0f7cc-ab84-4639-a6f5-120d3c5f4cff__Value" class="">Email</label>
_54
<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"/>
_54
<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>
_54
</div>
_54
</div>
_54
<div data-sc-field-key="9BD1C73AC2434C26963318B142BE9DE5" class="field-container">
_54
<label for="fxb_00000000-0000-0000-0000-000000000000_Fields_4684706c-25db-483a-b807-6f005332f580__Value" class="">Questions/Comments</label>
_54
<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>
_54
<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>
_54
</div>
_54
<input value="SUBMIT" type="submit" class=" sitecoreformbutton" name="fxb.00000000-0000-0000-0000-000000000000.e661c967-1a53-45b6-9fab-39ae73ae053e"/>
_54
<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"/>
_54
<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"/>
_54
</div>
_54
</form>

Cloudflare Worker

The Worker proxies Sitecore form submission requests and makes siteverify requests.


_85
export default {
_85
async fetch(request, env) {
_85
const url = new URL(request.url)
_85
_85
if (request.method === 'POST' && url.pathname == '/formbuilder') {
_85
console.log(url.pathname)
_85
return handlePost(request, env);
_85
}
_85
_85
return await fetch(request);
_85
},
_85
};
_85
_85
async function handlePost(request, env) {
_85
// DOCS https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
_85
_85
// Test secrets https://developers.cloudflare.com/turnstile/troubleshooting/testing/#dummy-sitekeys-and-secret-keys
_85
// 1x0000000000000000000000000000000AA Always passes
_85
// 2x0000000000000000000000000000000AA Always fails
_85
// 3x0000000000000000000000000000000AA Yields a "token already spent" error
_85
// Dummy secret keys will only accept the XXXX.DUMMY.TOKEN.XXXX dummy response token.
_85
_85
const formRequest = request.clone();
_85
const formBody = await formRequest.formData();
_85
_85
// There may be more than one token in the form data
_85
const tokens = formBody.getAll('cf-turnstile-response');
_85
const ip = request.headers.get('CF-Connecting-IP');
_85
_85
let tokenValidated = false;
_85
_85
for (const token of tokens) {
_85
_85
if (!token?.trim().length) continue; // skip empty or blank tokens
_85
_85
const siteVerifyData = new FormData();
_85
_85
siteVerifyData.append('secret', env.TURNSTILE_SECRET_KEY);
_85
siteVerifyData.append('response', token);
_85
siteVerifyData.append('remoteip', ip);
_85
_85
const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
_85
method: 'POST',
_85
body: siteVerifyData
_85
});
_85
_85
const outcome = await result.json();
_85
_85
if (outcome.success) {
_85
tokenValidated = true;
_85
break;
_85
}
_85
else {
_85
// TODO: could log failure reasons if needed https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes
_85
}
_85
}
_85
_85
if (!tokenValidated) {
_85
return new Response(
_85
'A validation error occurred. Please complete the validation and submit again. If the issue persists, contact us via phone or email.', {
_85
status: 400,
_85
headers: {
_85
'Content-Type': 'text/html'
_85
}
_85
});
_85
}
_85
_85
// Validation passed -- continue with the POST to Sitecore Forms
_85
const clonedRequest = request.clone();
_85
_85
const newRequest = new Request(clonedRequest, {
_85
body: clonedRequest.body,
_85
method: request.method,
_85
headers: request.headers,
_85
});
_85
_85
let formResponse = await fetch(newRequest);
_85
let responseText = await formResponse.text();
_85
_85
return new Response(responseText, {
_85
status: formResponse.status,
_85
statusText: formResponse.statusText,
_85
headers: formResponse.headers,
_85
});
_85
}

Cloudflare Snippet

The Snippet injects the Turnstile script reference, injects widgets, and responds to various states.


_268
// DEVELOPER DOCS https://developers.cloudflare.com/turnstile/get-started/
_268
export default {
_268
async fetch(request) {
_268
const response = await fetch(request);
_268
const contentType = response.headers.get("Content-Type");
_268
_268
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
_268
// 1x00000000000000000000AA Always passes visible
_268
// 2x00000000000000000000AB Always blocks visible
_268
// 1x00000000000000000000BB Always passes invisible
_268
// 2x00000000000000000000BB Always blocks invisible
_268
// 3x00000000000000000000FF Forces an interactive challenge visible
_268
// These dummy sitekeys will produce the XXXX.DUMMY.TOKEN.XXXX dummy response token.
_268
// Production secret keys will reject this token. You must also use a dummy secret key for testing purposes.
_268
_268
const TURNSTILE_SITE_ID = "CHANGE_ME";
_268
_268
if (!contentType || !contentType.includes("text/html")) {
_268
return response;
_268
}
_268
_268
const scriptToInject = `
_268
<script>
_268
document.addEventListener("DOMContentLoaded", function() {
_268
const SITECORE_FORMS_SELECTOR = 'form[id^="fxb"]';
_268
_268
const ERROR_MESSAGE_CONTAINER_STYLE = 'display:block;max-width:150px;';
_268
const ERROR_MESSAGE_CONTAINER_SELECTOR_CLASS = 'turnstile-error-message';
_268
_268
const VISIBLE_ERROR_CONTAINER_CLASSES = 'error-message field-validation-error ' + ERROR_MESSAGE_CONTAINER_SELECTOR_CLASS;
_268
const VISIBLE_WIDGET_CONTAINER_STYLES = 'display:block;margin-bottom:25px;';
_268
_268
// If the invisible widget must be rendered (rare cases in which interaction is required), do so in a compact fashion to ensure mobile friendliness
_268
const TURNSTILE_NORMAL_SIZE_MIN_PX = 429;
_268
const TURNSTILE_SIZE_SETTING = window.innerWidth >= TURNSTILE_NORMAL_SIZE_MIN_PX ? 'normal' : 'compact';
_268
_268
const ERROR_MESSAGE = 'Please complete the verification and submit the form again. If the issue persists, contact us via phone or email.';
_268
_268
if (!document.querySelectorAll(SITECORE_FORMS_SELECTOR).length) return;
_268
_268
window.attachGlobalSitecoreFormObserver = function() {
_268
const observer = new MutationObserver(mutations => {
_268
mutations.forEach(mutation => {
_268
mutation.removedNodes.forEach(node => {
_268
if (
_268
node.nodeType === 1 &&
_268
node.tagName === 'FORM' &&
_268
node.id &&
_268
node.id.startsWith('fxb_')
_268
) {
_268
console.debug('A Sitecore form was removed:', node);
_268
}
_268
});
_268
_268
// Added forms
_268
mutation.addedNodes.forEach(node => {
_268
// If the new node *is* a form
_268
if (
_268
node.nodeType === 1 &&
_268
node.tagName === 'FORM' &&
_268
node.id &&
_268
node.id.startsWith('fxb_')
_268
) {
_268
console.debug('A new Sitecore form was added:', node);
_268
window.injectTurnstileWidget(node);
_268
window.bindAjaxHandlers(node);
_268
}
_268
// If the node *contains* new forms
_268
else if (node.nodeType === 1) {
_268
const forms = node.querySelectorAll('form[id^="fxb_"]');
_268
forms.forEach(form => {
_268
console.log('A new Sitecore form was found inside a container:', form);
_268
window.injectTurnstileWidget(form);
_268
window.bindAjaxHandlers(form);
_268
});
_268
}
_268
});
_268
});
_268
});
_268
_268
// Observe entire <body> for scenarios in which forms may be added or replaced for any reason
_268
observer.observe(document.body, {
_268
childList: true,
_268
subtree: true
_268
});
_268
};
_268
_268
attachGlobalSitecoreFormObserver();
_268
_268
window.bindAjaxHandlers = function(sitecoreForm) {
_268
// Inject form failure handling for when siteverify fails. Append rather than replace in order not to blow away existing functionality.
_268
const existingFailureHandler = (sitecoreForm.getAttribute("data-ajax-failure") || "").trim();
_268
let needsSemicolon = existingFailureHandler && !existingFailureHandler.endsWith(";");
_268
let separator = existingFailureHandler ? " " : "";
_268
let newHandler = "window.handleFormFailure(arguments, this);";
_268
sitecoreForm.setAttribute(
_268
"data-ajax-failure",
_268
(needsSemicolon ? existingFailureHandler + ";" : existingFailureHandler) + separator + newHandler
_268
);
_268
};
_268
_268
window.turnstileCallback = function(token) {
_268
console.debug('Turnstile challenge returned the token / value:', token);
_268
};
_268
_268
window.displayTurnstileErrorMessage = function(sitecoreFormElement) {
_268
const submitButton = sitecoreFormElement.querySelector('input[type="submit"], button[type="submit"]');
_268
if (!submitButton){
_268
// Can occur when displaying a second form page that does not have a submit button
_268
// There may be a scenario in which this needs to be handled instead of returning early
_268
console.debug("No submit button found -- will not display widget");
_268
return;
_268
}
_268
_268
const existingErrorMessage = sitecoreFormElement.querySelector('.' + ERROR_MESSAGE_CONTAINER_SELECTOR_CLASS);
_268
if (!existingErrorMessage) {
_268
const errorDiv = document.createElement("div");
_268
_268
errorDiv.className = VISIBLE_ERROR_CONTAINER_CLASSES;
_268
errorDiv.innerText = ERROR_MESSAGE;
_268
errorDiv.style = ERROR_MESSAGE_CONTAINER_STYLE;
_268
_268
submitButton.parentNode.insertBefore(errorDiv, submitButton);
_268
}
_268
};
_268
_268
// Invoked when there is an error (e.g. network error or the challenge failed).
_268
// If an error callback returns with a non-falsy result, Turnstile will assume that the error callback handled the error accordingly.
_268
window.turnstileErrorCallback = function(turnstileWidgetElement, errorCode) {
_268
console.warn('An error occurred while fetching Turnstile token.', { turnstileWidgetElement }, { errorCode });
_268
_268
// Display the widget so that the user can see the error
_268
turnstileWidgetElement.style = VISIBLE_WIDGET_CONTAINER_STYLES;
_268
_268
// Display error message calling for user to perform the verification
_268
window.displayTurnstileErrorMessage(turnstileWidgetElement.closest('form'))
_268
_268
return false;
_268
};
_268
_268
window.turnstileBeforeInteractiveCallback = function(turnstileWidgetElement) {
_268
// Display the widget so that the user can interact with it
_268
turnstileWidgetElement.style = VISIBLE_WIDGET_CONTAINER_STYLES;
_268
};
_268
_268
window.injectTurnstileWidget = function(sitecoreFormElement) {
_268
const submitButton = sitecoreFormElement.querySelector('input[type="submit"], button[type="submit"]');
_268
if (!submitButton){
_268
// This can happen when the form is successfully submitted and is now displaying a thank you page
_268
console.warn('Unable to locate submit button -- will not inject turnstile widget');
_268
return;
_268
}
_268
_268
const existingTurnstileWidget = sitecoreFormElement.querySelector('.turnstile-widget');
_268
if (existingTurnstileWidget){
_268
console.warn('Turnstile elements already present -- will not inject');
_268
return;
_268
}
_268
_268
const turnstileDiv = document.createElement("div");
_268
turnstileDiv.className = sitecoreFormElement.id + "-turnstile turnstile-widget";
_268
turnstileDiv.setAttribute("style", "display:none;");
_268
_268
submitButton.parentNode.insertBefore(turnstileDiv, submitButton);
_268
_268
let cdata = sitecoreFormElement.id
_268
// Replace any characters that are NOT letters, numbers, underscores, or hyphens with a hyphen
_268
.replace(/[^a-zA-Z0-9_-]/g, '-')
_268
// Limit to 255 chars to comply with the Turnstile cData limit
_268
.substring(0, 255);
_268
_268
const widgetId = turnstile.render(turnstileDiv, {
_268
// https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations
_268
sitekey: "${TURNSTILE_SITE_ID}",
_268
callback: (token) => window.turnstileCallback(token),
_268
'error-callback': (errorCode) => window.turnstileErrorCallback(turnstileDiv, errorCode),
_268
'before-interactive-callback': () => window.turnstileBeforeInteractiveCallback(turnstileDiv),
_268
'size': TURNSTILE_SIZE_SETTING,
_268
'cData': cdata,
_268
'theme': 'light'
_268
});
_268
_268
if (widgetId == undefined) {
_268
// TODO: handle unsuccessful invocation of turnstile render
_268
console.error("Failed to render turnstile on", { turnstileDiv } )
_268
}
_268
else {
_268
console.debug("Rendered turnstile", { widgetId });
_268
}
_268
};
_268
_268
// Called when the form receives a non 200 response after submitting
_268
window.handleFormFailure = function (args, contextElement) {
_268
console.debug("Form submission failed", {args}, {contextElement});
_268
_268
const errorCode = args?.status;
_268
const responseText = args?.responseText;
_268
_268
// TODO: consider case when status is something other than 400; particularly 500 errors
_268
if (args?.status == "400" || args?.status == 400){
_268
console.debug('Error response code', errorCode);
_268
console.debug('Response text', responseText);
_268
}
_268
_268
const form = contextElement?.closest?.('form');
_268
if (!form) {
_268
console.warn("Could not locate form from context element.");
_268
return;
_268
}
_268
_268
const submitButton = form.querySelector('input[type="submit"], button[type="submit"]');
_268
if (!submitButton){
_268
// Can occur when displaying a second form page that does not have a submit button
_268
// There may be a scenario in which this needs to be handled instead of returning early
_268
console.debug("No submit button found -- will not display widget");
_268
return;
_268
}
_268
_268
// Display the interactive widget either via window.injectTurnstileWidget(form); or by displaying the hidden managed widget
_268
const widget = form.querySelector('.turnstile-widget');
_268
if (widget){
_268
turnstile.reset(widget);
_268
widget.style = VISIBLE_WIDGET_CONTAINER_STYLES;
_268
} else {
_268
// TODO: handle
_268
console.error('No turnstile present in the form');
_268
}
_268
_268
window.displayTurnstileErrorMessage(form);
_268
_268
// Re-enable the button and change text back to "Submit"
_268
submitButton.disabled = false;
_268
_268
if (submitButton.tagName === 'INPUT') {
_268
submitButton.value = "Submit";
_268
} else {
_268
submitButton.innerText = "Submit";
_268
}
_268
_268
};
_268
_268
document.querySelectorAll(SITECORE_FORMS_SELECTOR)?.forEach(sitecoreForm => {
_268
window.injectTurnstileWidget(sitecoreForm);
_268
window.bindAjaxHandlers(sitecoreForm);
_268
});
_268
});
_268
</script>
_268
`;
_268
_268
return new HTMLRewriter()
_268
.on("head", {
_268
element(element) {
_268
element.append(
_268
// https://developers.cloudflare.com/turnstile/tutorials/implicit-vs-explicit-rendering
_268
// For explicit rendering use https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onloadTurnstileCallback
_268
`<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>`,
_268
{ html: true }
_268
);
_268
},
_268
})
_268
.on("body", {
_268
element(element) {
_268
element.append(scriptToInject, { html: true });
_268
},
_268
})
_268
.transform(response);
_268
},
_268
};

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


More Stories

Cover Image for Tips for Applying Cumulative Sitecore XM/XP Patches and Hotfixes

Tips for Applying Cumulative Sitecore XM/XP Patches and Hotfixes

> It's probably time to overhaul your processes

Cover Image for Hello World

Hello World

> Welcome to the show

Cover Image for Don't Ignore the HttpRequestValidationException

Don't Ignore the HttpRequestValidationException

> Doing so could be... potentially dangerous

Cover Image for SPE Script Performance & Troubleshooting

SPE Script Performance & Troubleshooting

> Script never ends or runs too slow? Get in here.

Cover Image for Troubleshooting 502 Responses in Azure App Services

Troubleshooting 502 Responses in Azure App Services

> App Services don't support all libraries

Cover Image for Tips for New Sitecore Developers

Tips for New Sitecore Developers

> If I had more time, I would have written a shorter letter

Cover Image for Year in Review: 2022

Year in Review: 2022

> Full steam ahead

Cover Image for JSS + TypeScript Sitecore Project Tips

JSS + TypeScript Sitecore Project Tips

> New tech, new challenges

Cover Image for How to Run Sitecore 10.3.x in Docker on Windows 10

How to Run Sitecore 10.3.x in Docker on Windows 10

> Configs for loading useful asset images

Cover Image for Content Editor Search Bar Not Working

Content Editor Search Bar Not Working

> Sometimes it works, sometimes not

Cover Image for Tips for Forms Implementations

Tips for Forms Implementations

> And other pro tips

Cover Image for NextJS: Short URL for Viewing Layout Service Response

NextJS: Short URL for Viewing Layout Service Response

> Because the default URL is 2long4me

Cover Image for NextJS/JSS Edit Frames Before JSS v21.1.0

NextJS/JSS Edit Frames Before JSS v21.1.0

> It is possible. We have the technology.

Cover Image for Security Series: App Service IP Restrictions

Security Series: App Service IP Restrictions

> How to manage IP rules "at scale" using the Azure CLI

Cover Image for Sitecore Symposium 2022

Sitecore Symposium 2022

> What I'm Watching 👀

Cover Image for JSS: Reducing Bloat in Multilist Field Serialization

JSS: Reducing Bloat in Multilist Field Serialization

> Because: performance, security, and error-avoidance

Cover Image for Script: Boost SIF Certificate Expiry Days

Script: Boost SIF Certificate Expiry Days

> One simple script that definitely won't delete your system32 folder

Cover Image for NextJS: Unable to Verify the First Certificate

NextJS: Unable to Verify the First Certificate

> UNABLE_TO_VERIFY_LEAF_SIGNATURE

Cover Image for On Sitecore Stack Exchange (SSE)

On Sitecore Stack Exchange (SSE)

> What I've learned, what I see, what I want to see

Cover Image for How to Run Old Versions of Solr in a Docker Container

How to Run Old Versions of Solr in a Docker Container

> Please don't make me install another version of Solr on my local...

Cover Image for Azure PaaS Cache Optimization

Azure PaaS Cache Optimization

> App Services benefit greatly from proper configuration

Cover Image for On Mentorship and Community Contributions

On Mentorship and Community Contributions

> Reflections and what I learned as an MVP mentor

Cover Image for Super Fast Project Builds with Visual Studio Publish

Super Fast Project Builds with Visual Studio Publish

> For when solution builds take too long

Cover Image for Critical Security Bulletin SC2024-001-619349 Announced

Critical Security Bulletin SC2024-001-619349 Announced

> And other scintillating commentary

Cover Image for Add TypeScript Type Checks to RouteData fields

Add TypeScript Type Checks to RouteData fields

> Inspired by error: Conversion of type may be a mistake because neither type sufficiently overlaps with the other.

Cover Image for Ideas For Docker up.ps1 Scripts

Ideas For Docker up.ps1 Scripts

> Because Docker can be brittle

Cover Image for Early Returns in React Components

Early Returns in React Components

> When and how should you return early in a React component?

Cover Image for On Sitecore Development

On Sitecore Development

> Broadly speaking

Cover Image for Symposium 2022 Reflections

Symposium 2022 Reflections

> Sitecore is making big changes

Cover Image for Considerations for Hosting Mail Signature Images on Vercel

Considerations for Hosting Mail Signature Images on Vercel

> Outlook is a Cache-Control disrepectoor and that's a problem

Cover Image for NextJS: Access has been blocked by CORS policy

NextJS: Access has been blocked by CORS policy

> CORS is almost as much of a nuisance as GDPR popups