diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts
index 834093e48bc7..1806123cbd7b 100644
--- a/web/src/flow/stages/captcha/CaptchaStage.ts
+++ b/web/src/flow/stages/captcha/CaptchaStage.ts
@@ -529,6 +529,7 @@ export class CaptchaStage
const template = iframeTemplate(captchaElement, {
challengeURL: challengeURL.toString(),
theme: this.activeTheme,
+ scriptOnLoad: !(controller instanceof TurnstileController),
});
if (
diff --git a/web/src/flow/stages/captcha/controllers/turnstile.ts b/web/src/flow/stages/captcha/controllers/turnstile.ts
index eb5e21bdc44d..79ceafce7cce 100644
--- a/web/src/flow/stages/captcha/controllers/turnstile.ts
+++ b/web/src/flow/stages/captcha/controllers/turnstile.ts
@@ -1,7 +1,5 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
///
-import { ifPresent } from "#elements/utils/attributes";
-
import { CaptchaController } from "#flow/stages/captcha/controllers/CaptchaController";
import { TurnstileObject } from "turnstile-types";
@@ -20,7 +18,16 @@ export class TurnstileController extends CaptchaController {
public prepareURL = (): URL | null => {
const input = this.host.challenge?.jsUrl;
- return input && URL.canParse(input) ? new URL(input) : null;
+ if (!input || !URL.canParse(input)) return null;
+
+ const url = new URL(input);
+
+ // Use explicit rendering to prevent Turnstile's 3-hour self-upgrade
+ // from calling implicitRenderAll() and duplicating widgets.
+ url.searchParams.set("render", "explicit");
+ url.searchParams.set("onload", "onTurnstileReady");
+
+ return url;
};
/**
@@ -33,25 +40,34 @@ export class TurnstileController extends CaptchaController {
/**
* Renders the Turnstile captcha frame.
*
+ * Uses explicit rendering to avoid Turnstile's self-upgrade mechanism
+ * (every ~3 hours) from calling `implicitRenderAll()` and duplicating widgets.
+ *
* @remarks
*
- * Turnstile will log a warning if the `data-language` attribute
+ * Turnstile will log a warning if the `language` option
* is not in lower-case format.
*
* @see {@link https://developers.cloudflare.com/turnstile/reference/supported-languages/ Turnstile Supported Languages}
*/
public interactive = () => {
- const languageTag = this.host.activeLanguageTag.toLowerCase();
+ const siteKey = this.host.challenge?.siteKey ?? "";
+ const theme = this.host.activeTheme;
+ const language = this.host.activeLanguageTag.toLowerCase();
- return html`
`;
+ return html`
+ `;
};
public refreshInteractive = async () => {
diff --git a/web/src/flow/stages/captcha/shared.ts b/web/src/flow/stages/captcha/shared.ts
index 850347909e6a..5245809e7f72 100644
--- a/web/src/flow/stages/captcha/shared.ts
+++ b/web/src/flow/stages/captcha/shared.ts
@@ -25,6 +25,11 @@ export function themeMeta(theme: ResolvedUITheme) {
export interface IFrameTemplateInit {
challengeURL: URL | string;
theme: ResolvedUITheme;
+ /**
+ * If `true`, the script element will fire `loadListener()` on load.
+ * Defaults to `true`.
+ */
+ scriptOnLoad?: boolean;
}
/**
@@ -37,7 +42,7 @@ export interface IFrameTemplateInit {
*/
export function iframeTemplate(
children: TemplateResult,
- { challengeURL, theme }: IFrameTemplateInit,
+ { challengeURL, theme, scriptOnLoad = true }: IFrameTemplateInit,
) {
return createDocumentTemplate({
head: html`
@@ -90,7 +95,10 @@ export function iframeTemplate(
}
${children}
-
+
`,
});
}