reCAPTCHA v3 on Salesforce Experience Cloud: Why the Script Loads but Never Returns a Token
The reCAPTCHA script returns 200, the badge never appears, and grecaptcha is undefined inside your LWC. Why Lightning Web Security blocks it, and the fix.
You add Google reCAPTCHA v3 to an intake form built as a Lightning Web
Component on an Experience Cloud site. The wiring looks textbook:
loadScript pulls in recaptcha/api.js?render=KEY, the form calls
grecaptcha.execute() on submit, and Apex verifies the token server-side.
You deploy.
The Network tab shows api.js returning 200. And then nothing. No badge
in the corner, grecaptcha comes back undefined, and every submission is
rejected because the token is empty. The script loaded. The library isn’t
there. Nothing errored.
This one cost me the better part of a day, so here’s the whole trap laid out.
Failure one: loadScript can’t start reCAPTCHA
reCAPTCHA’s api.js bootstraps itself by finding its own <script> tag in
the document — that’s how it reads the render key and decides to render the
badge and stand up the grecaptcha object. Inject it through loadScript
from inside a component and it can’t find the tag it expects, so it quietly
declines to initialize. The file downloads (your 200), the library never
wakes up. No badge, no grecaptcha, no error to chase.
Failure two: Lightning Web Security walls off the page
So you move the script to where it can bootstrap — the page itself — and
confirm in the browser console that grecaptcha is now a real object. Submit
the form anyway, and it’s still rejected.
Here’s the part that turns a one-hour fix into a one-day fix: Lightning Web
Security gives each component its own sandboxed window, isolated from page
globals. A library a page-level script attaches to window is not
automatically visible inside your component. The page console says
grecaptcha is an object; the component says it’s undefined. Both are
telling the truth about their own realm.
A thirty-second diagnostic settles it — have the component report what it sees:
this.captchaDebug = 'grecaptcha=' + (typeof window.grecaptcha);
If the page shows object and the component shows undefined, stop
debugging your code. You’re looking at the LWS realm boundary.
The fix: load on the page, bridge with DOM events
Two moves. First, load reCAPTCHA at the site level so it bootstraps in the page realm — Experience Builder → Settings → Advanced → Edit Head Markup:
<script src="https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY"></script>
The badge renders and grecaptcha is real — on the page. Now bridge your
component to it. The component can’t call grecaptcha directly, but the DOM
is shared across realms, so events cross the boundary. Add a second
head-markup script that listens for a request, runs the check, and hands the
token back:
<script>
document.addEventListener('captcha:request', function () {
grecaptcha.ready(function () {
grecaptcha.execute('YOUR_SITE_KEY', { action: 'submit' })
.then(function (token) {
document.dispatchEvent(new CustomEvent('captcha:token', { detail: token }));
});
});
});
</script>
And in the component, ask for a token and wait for the answer:
getCaptchaToken() {
return new Promise((resolve) => {
const handler = (e) => {
document.removeEventListener('captcha:token', handler);
resolve(typeof e.detail === 'string' ? e.detail : '');
};
document.addEventListener('captcha:token', handler);
document.dispatchEvent(new CustomEvent('captcha:request'));
window.setTimeout(() => resolve(''), 6000); // fail open if no answer comes back
});
}
The one rule that bites you if you miss it: only a primitive survives the
LWS boundary in event detail. Pass the token as a bare string. Wrap it in
an object — { detail: { token } } — and LWS strips the payload on the way
across, and you’re right back to empty tokens with nothing to show for it.
The rest is ordinary: the component sends the token to Apex, Apex POSTs it to
https://www.google.com/recaptcha/api/siteverify (you’ll need a Remote Site
Setting for google.com), checks success and score, and rejects anything
under your threshold.
Before you build all that
This is real plumbing — a head-markup script, a DOM event-bridge, a config record for the keys, an Apex callout. For a public marketing form under constant bot fire, it earns its keep. For a low-traffic intake form serving one organization, a honeypot — an off-screen field humans never fill and bots do — stops most of the junk with none of the moving parts. Reach for the scoring captcha when you can show you actually need it.
Spam is only one way an intake form fails you, too. A form that submits perfectly can still be quietly dropping the fields you most need — worth a look before you trust any form you didn’t round-trip yourself.
And the habit worth keeping: when a library you “loaded” is undefined
inside a Lightning Web Component, suspect the security realm before you
suspect yourself. The page and the component do not see the same window.