Standard document.querySelector can't reach inside shadow roots. When a CAPTCHA widget is rendered inside a web component's shadow DOM, your usual sitekey extraction script returns null. The CAPTCHA is there — your selector just can't see it.
Why CAPTCHAs End Up in Shadow DOM
| Scenario | Reason |
|---|---|
| Custom login component | Encapsulated as a reusable web component |
| Third-party form widget | Widget vendor wraps entire form in shadow root |
| Micro-frontend architecture | Each micro-app uses isolated shadow DOM |
| Design system components | CAPTCHA embedded in component library element |
Detecting Shadow DOM CAPTCHAs
Before solving, confirm the CAPTCHA is inside a shadow root:
// In browser DevTools console
// Regular query returns null even though CAPTCHA is visible
document.querySelector('.cf-turnstile'); // null
// Check for shadow hosts
document.querySelectorAll('*').forEach(el => {
if (el.shadowRoot) {
const captcha = el.shadowRoot.querySelector('.cf-turnstile, .g-recaptcha');
if (captcha) {
console.log('Found CAPTCHA in shadow root of:', el.tagName, el.id || el.className);
console.log('Sitekey:', captcha.dataset.sitekey);
}
}
});
Python: Playwright Shadow DOM Piercing
Playwright's >> piercing selector and locator API handle shadow DOM natively:
import requests
import time
from playwright.sync_api import sync_playwright
API_KEY = "YOUR_API_KEY"
SUBMIT_URL = "https://ocr.captchaai.com/in.php"
RESULT_URL = "https://ocr.captchaai.com/res.php"
def solve_turnstile(sitekey, pageurl):
"""Submit and poll a Turnstile CAPTCHA."""
resp = requests.post(SUBMIT_URL, data={
"key": API_KEY,
"method": "turnstile",
"sitekey": sitekey,
"pageurl": pageurl,
"json": 1,
}, timeout=30).json()
if resp.get("status") != 1:
raise RuntimeError(f"Submit failed: {resp.get('request')}")
task_id = resp["request"]
for _ in range(60):
time.sleep(5)
poll = requests.get(RESULT_URL, params={
"key": API_KEY, "action": "get",
"id": task_id, "json": 1,
}, timeout=15).json()
if poll.get("request") == "CAPCHA_NOT_READY":
continue
if poll.get("status") == 1:
return poll["request"]
raise RuntimeError(f"Solve failed: {poll.get('request')}")
raise RuntimeError("Timeout")
def extract_from_shadow_dom(page):
"""Extract CAPTCHA sitekey from shadow DOM elements."""
# Method 1: Playwright's piercing selector (>>)
# This automatically crosses shadow boundaries
turnstile = page.locator("css=.cf-turnstile >> visible=true").first
if turnstile.count() > 0:
sitekey = turnstile.get_attribute("data-sitekey")
if sitekey:
return sitekey
# Method 2: JavaScript evaluation to pierce all shadow roots
sitekey = page.evaluate("""
() => {
function findInShadowRoots(root) {
// Check direct children
const turnstile = root.querySelector('.cf-turnstile');
if (turnstile && turnstile.dataset.sitekey) {
return turnstile.dataset.sitekey;
}
const recaptcha = root.querySelector('.g-recaptcha');
if (recaptcha && recaptcha.dataset.sitekey) {
return recaptcha.dataset.sitekey;
}
// Recurse into nested shadow roots
for (const el of root.querySelectorAll('*')) {
if (el.shadowRoot) {
const found = findInShadowRoots(el.shadowRoot);
if (found) return found;
}
}
return null;
}
return findInShadowRoots(document);
}
""")
return sitekey
def inject_token_shadow_dom(page, token, captcha_type="turnstile"):
"""Inject solved token into shadow DOM CAPTCHA element."""
if captcha_type == "turnstile":
page.evaluate(f"""
(token) => {{
function findAndInject(root) {{
// Find the response input inside Turnstile
const input = root.querySelector('[name="cf-turnstile-response"]');
if (input) {{
input.value = token;
return true;
}}
// Recurse into shadow roots
for (const el of root.querySelectorAll('*')) {{
if (el.shadowRoot && findAndInject(el.shadowRoot)) {{
return true;
}}
}}
return false;
}}
findAndInject(document);
// Also try callback if defined
if (typeof window.turnstileCallback === 'function') {{
window.turnstileCallback(token);
}}
}}
""", token)
elif captcha_type == "recaptcha":
page.evaluate(f"""
(token) => {{
function findAndInject(root) {{
const textarea = root.querySelector('#g-recaptcha-response');
if (textarea) {{
textarea.value = token;
textarea.style.display = 'block';
return true;
}}
for (const el of root.querySelectorAll('*')) {{
if (el.shadowRoot && findAndInject(el.shadowRoot)) {{
return true;
}}
}}
return false;
}}
findAndInject(document);
if (typeof ___grecaptcha_cfg !== 'undefined') {{
Object.entries(___grecaptcha_cfg.clients).forEach(([_, client]) => {{
Object.entries(client).forEach(([_, val]) => {{
if (val && typeof val === 'object') {{
Object.entries(val).forEach(([_, v]) => {{
if (v && v.callback) v.callback(token);
}});
}}
}});
}});
}}
}}
""", token)
def main():
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto("https://example.com/login")
page.wait_for_load_state("networkidle")
# Extract sitekey from shadow DOM
sitekey = extract_from_shadow_dom(page)
if not sitekey:
print("No CAPTCHA found in shadow DOM or regular DOM")
browser.close()
return
print(f"Found sitekey: {sitekey}")
# Solve via CaptchaAI
token = solve_turnstile(sitekey, page.url)
print(f"Solved: {token[:40]}...")
# Inject token back into shadow DOM
inject_token_shadow_dom(page, token, "turnstile")
print("Token injected into shadow DOM")
# Submit the form
page.click("button[type='submit']")
page.wait_for_load_state("networkidle")
browser.close()
main()
JavaScript: Puppeteer Shadow DOM Traversal
const puppeteer = require("puppeteer");
const API_KEY = "YOUR_API_KEY";
const SUBMIT_URL = "https://ocr.captchaai.com/in.php";
const RESULT_URL = "https://ocr.captchaai.com/res.php";
async function solveTurnstile(sitekey, pageurl) {
const params = new URLSearchParams({
key: API_KEY, method: "turnstile", sitekey, pageurl, json: "1",
});
const resp = await (await fetch(SUBMIT_URL, { method: "POST", body: params })).json();
if (resp.status !== 1) throw new Error(`Submit: ${resp.request}`);
const taskId = resp.request;
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 5000));
const url = `${RESULT_URL}?key=${API_KEY}&action=get&id=${taskId}&json=1`;
const poll = await (await fetch(url)).json();
if (poll.request === "CAPCHA_NOT_READY") continue;
if (poll.status === 1) return poll.request;
throw new Error(`Solve: ${poll.request}`);
}
throw new Error("Timeout");
}
async function extractSitekeyFromShadowDOM(page) {
return page.evaluate(() => {
function searchShadowRoots(root) {
const selectors = [".cf-turnstile", ".g-recaptcha", ".h-captcha"];
for (const sel of selectors) {
const el = root.querySelector(sel);
if (el && el.dataset.sitekey) return el.dataset.sitekey;
}
for (const el of root.querySelectorAll("*")) {
if (el.shadowRoot) {
const found = searchShadowRoots(el.shadowRoot);
if (found) return found;
}
}
return null;
}
return searchShadowRoots(document);
});
}
async function injectTokenShadowDOM(page, token) {
await page.evaluate((t) => {
function inject(root) {
const input = root.querySelector('[name="cf-turnstile-response"]');
if (input) { input.value = t; return true; }
const textarea = root.querySelector("#g-recaptcha-response");
if (textarea) { textarea.value = t; return true; }
for (const el of root.querySelectorAll("*")) {
if (el.shadowRoot && inject(el.shadowRoot)) return true;
}
return false;
}
inject(document);
}, token);
}
(async () => {
const browser = await puppeteer.launch({ headless: false });
const page = await browser.newPage();
await page.goto("https://example.com/login", { waitUntil: "networkidle2" });
const sitekey = await extractSitekeyFromShadowDOM(page);
if (!sitekey) {
console.log("No CAPTCHA found in shadow DOM");
await browser.close();
return;
}
console.log(`Sitekey: ${sitekey}`);
const token = await solveTurnstile(sitekey, page.url());
console.log(`Solved: ${token.substring(0, 40)}...`);
await injectTokenShadowDOM(page, token);
await page.click('button[type="submit"]');
await page.waitForNavigation();
await browser.close();
})();
Shadow DOM Depth Considerations
| Depth | Example | Approach |
|---|---|---|
| 1 level | <custom-form> #shadow-root > .cf-turnstile |
Direct shadow root query |
| 2+ levels | <app-shell> #shadow > <login-form> #shadow > .cf-turnstile |
Recursive traversal |
| Open shadow root | el.shadowRoot accessible |
Standard approach — use recursion |
| Closed shadow root | el.shadowRoot returns null |
Cannot pierce; use page.evaluate with attachShadow({mode:'open'}) override or intercept rendering |
Troubleshooting
| Issue | Cause | Fix |
|---|---|---|
shadowRoot returns null |
Closed shadow DOM | Override attachShadow before page loads to force mode: 'open' |
| Sitekey found but token rejected | Wrong pageurl sent to solver | Use page.url() at extraction time, not a hardcoded URL |
| Token injected but form doesn't submit | CAPTCHA callback not triggered | Find and call the callback function after setting the token value |
| Recursive search is slow | Deep DOM tree | Limit recursion depth; target known shadow host tag names |
| CAPTCHA not found on initial load | Widget loads asynchronously | Wait for the shadow host element first: page.waitForSelector('custom-form') |
FAQ
How do I know if a CAPTCHA uses shadow DOM?
In Chrome DevTools, inspect the CAPTCHA element. If you see #shadow-root (open) or #shadow-root (closed) above the CAPTCHA markup, it's inside a shadow root. Regular querySelector from document won't find it.
Can I avoid shadow DOM traversal entirely?
If you're using the CaptchaAI API directly (not browser injection), you only need the sitekey and pageurl — shadow DOM doesn't matter. Shadow DOM handling is only needed when you must extract sitekeys from the live page or inject tokens back into the DOM.
What about closed shadow roots?
Closed shadow roots block el.shadowRoot access. The workaround is to override Element.prototype.attachShadow before the page loads, forcing all shadow roots to open mode. This works in Playwright and Puppeteer via page.evaluateOnNewDocument.
Related Articles
- Geetest Vs Cloudflare Turnstile Comparison
- Cloudflare Turnstile 403 After Token Fix
- Cloudflare Turnstile Widget Modes Explained
Next Steps
Handle CAPTCHAs hidden inside web components — get your CaptchaAI API key and implement shadow DOM traversal.
Related guides:
Discussions (0)
Join the conversation
Sign in to share your opinion.
Sign InNo comments yet.