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
| Approach | Effort | Description |
|---|---|---|
| Clean migration | Medium | Remove Turnstile entirely, integrate MANDATE directly |
| Shim migration | Low | Drop-in compatibility layer that maps Turnstile APIs to MANDATE |
Clean Migration
Remove the Turnstile Script
<!-- Remove this -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>Remove the Turnstile Widget
<!-- Remove this -->
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>Add the MANDATE Script
<script async src="https://gate.x-lock.dev/g.js?k=YOUR_SITE_KEY"></script>Update Frontend Token Retrieval
turnstile.render("#container", {
sitekey: "YOUR_SITE_KEY",
callback: function(token) {
document.getElementById("token-field").value = token;
},
});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:
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
}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-turnstileclass references
Shim Migration
Swap Script Tags
<!-- 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:
<!-- 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 API | Shim 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:
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
| Feature | Turnstile | MANDATE |
|---|---|---|
| Visible widget | Sometimes (managed/interactive mode) | Never |
| Backend verification | API call to Cloudflare | Local cryptographic check |
| Token delivery | Hidden form field or callback | HttpOnly Mndt-Sess in production; X-Mndt only for compatibility |
| Latency | Network round-trip for verify | No MANDATE round trip for compatible local verification |
| Cloudflare dependency | Required | None |
| Token TTL | 300 seconds | 900 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.