DEVELOPER DOCUMENTATION

Migrate from Turnstile

Replace Cloudflare Turnstile with MANDATE bot detection.

Start in Observe on one route. Use the deployment URL and installed package version supplied for your workspace. Historical host examples and source capabilities do not establish a live service or published release.

Warning

Use the unpublished reviewed kit candidate for these server examples, not npm

0.3.0. Normal protected browser actions require hosted live Gate authority.

Legacy CAPTCHA shims preserve callback/form compatibility only during bounded

migration; they are not production browser authority. Hidden response fields

alone cannot replace cookie/request proof. Complete the first-party hosted

rollout before removing the previous production control.

Migration Approaches

ApproachEffortDescription
Clean migrationMediumRemove Turnstile entirely, integrate MANDATE directly
Shim migrationLowDrop-in compatibility layer that maps Turnstile APIs to MANDATE

Clean Migration

Remove the Turnstile Script

html
<!-- Remove this -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

Remove the Turnstile Widget

html
<!-- Remove this -->
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>

Add the MANDATE Script

html
<script async src="https://gate.x-lock.dev/g.js?k=YOUR_SITE_KEY"></script>

Update Frontend Token Retrieval

javascript Before (Turnstile)
turnstile.render("#container", {
  sitekey: "YOUR_SITE_KEY",
  callback: function(token) {
    document.getElementById("token-field").value = token;
  },
});
javascript After (MANDATE)
await fetch("/api/action", { method: "POST" });
// Production same-origin protected requests carry Mndt-Sess automatically.

See Frontend Integration for the same-origin request-boundary contract.

Update Backend Verification

Replace the Turnstile siteverify API call:

javascript Before (Turnstile)
const response = await fetch(
  "https://challenges.cloudflare.com/turnstile/v0/siteverify",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      secret: TURNSTILE_SECRET,
      response: token,
    }),
  }
);
const data = await response.json();
if (!data.success) {
  // Block request
}
javascript After (MANDATE)
import { validateHostedRequest } from "@mandate-security/node";

const result = await validateHostedRequest(req, {
  siteSecret: process.env.MANDATE_SITE_SECRET,
  siteKey: process.env.MANDATE_SITE_KEY,
  apiKey: process.env.MANDATE_API_KEY,
  gateUrl: process.env.MANDATE_GATE_URL,
  credentialMode: "cookie", credentialCookieName: "Mndt-Sess",
  highValue: true, origin: "https://app.example.com",
});
if (!result.success) {
  // Keep the protected action denied. Prefer a framework adapter so
  // authenticated cookie/companion/native updates are applied too.
}

Clean Up

  • Remove Turnstile secret key from environment variables
  • Remove any Turnstile-specific CSS or container elements
  • Remove cf-turnstile class references

Shim Migration

Swap Script Tags

html
<!-- Remove this -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<!-- Add these (order matters) -->
<script async src="https://gate.x-lock.dev/g.js?k=YOUR_SITE_KEY"></script>
<script src="https://gate.x-lock.dev/compat/mandate-shim-core.js"></script>
<script src="https://gate.x-lock.dev/compat/turnstile-shim.js"></script>

Info

Shims are served from the gate at /compat/*. Load order: g.js → mandate-shim-core.js → provider shim. The core reads the legacy compatibility bridge only and never calls polymorphic g.js globals. Enable MANDATE_TOKEN_BRIDGE=legacy only in non-production shim migration tests; production rejects page-visible token bridges.

Remove Widget Containers

The shim handles turnstile.render() calls but you can remove the container divs since MANDATE does not render any visible element:

html
<!-- Can be removed (shim handles render calls as no-ops) -->
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>

Verify Existing Code Works

The shim provides the following Turnstile API surface:

Turnstile APIShim Behavior
turnstile.render(container, options)No-op, calls options.callback with token when ready
turnstile.execute(container, options)Returns MANDATE token via callback/Promise
turnstile.getResponse(widgetId)Returns current MANDATE token
turnstile.reset(widgetId)Triggers token refresh
turnstile.remove(widgetId)No-op
turnstile.isExpired(widgetId)Returns true if token TTL exceeded

Update Backend Verification

Replace the Turnstile siteverify call with MANDATE SDK:

javascript
import { validateHostedRequest } from "@mandate-security/node";

const result = await validateHostedRequest(req, {
  siteSecret: process.env.MANDATE_SITE_SECRET,
  siteKey: process.env.MANDATE_SITE_KEY,
  apiKey: process.env.MANDATE_API_KEY,
  gateUrl: process.env.MANDATE_GATE_URL,
  credentialMode: "cookie", credentialCookieName: "Mndt-Sess",
  highValue: true, origin: "https://app.example.com",
});
if (!result.success) {
  // Keep the protected action denied. Prefer a framework adapter so
  // authenticated cookie/companion/native updates are applied too.
}

Info

The shim populates hidden form fields named cf-turnstile-response for compatibility with forms that submit tokens via POST body.

Remove the Shim

Once stable, remove the shim scripts, disable MANDATE_TOKEN_BRIDGE=legacy, and move production browser routes to first-party cookie delivery and hosted validation (see Frontend Integration).

Key Differences

FeatureTurnstileMANDATE
Visible widgetSometimes (managed/interactive mode)Never
Backend verificationAPI call to CloudflareLocal cryptographic check
Token deliveryHidden form field or callbackHttpOnly Mndt-Sess in production; X-Mndt only for compatibility
LatencyNetwork round-trip for verifyNo MANDATE round trip for compatible local verification
Cloudflare dependencyRequiredNone
Token TTL300 seconds900 seconds (15 min)

Tip

MANDATE tokens have a longer TTL than Turnstile (15 min vs 5 min), which reduces token refresh frequency on long-lived pages.