Skip to content
GRUDGED Free audit
§ Field notes for Salesforce teams ·

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.

← All field notes Talk to Chris Moore →