How to Solve reCAPTCHA in CefSharp OffScreen Console Apps

To solve reCAPTCHA in CefSharp, load the page in an OffScreen ChromiumWebBrowser, read the sitekey with a script run through browser.GetMainFrame(), and pass it to CapSkip’s RecaptchaAsync. Then write the token into every g-recaptcha-response field and call the page’s callback or submit the form. Two CefSharp rules break most first attempts. Calling EvaluateScriptAsync on the browser itself right after a navigation throws almost every time, because the page’s JavaScript context is not ready yet. And Cef.Shutdown must run on the thread that started CEF, which an async Main quietly breaks. This guide builds a small console app that handles both, plus invisible widgets, explicit renders and several pages at once.
What you need
- CapSkip running on a Windows machine, with the reCAPTCHA solver switched on. CefSharp is Windows-only as well, so both can share one PC.
- The .NET 10 SDK, or the .NET 8 SDK with net8.0-windows in Step 1. The code below was run on .NET 8 and .NET 10, built with SDK 10.0.100.
- The CefSharp.OffScreen.NETCore and CapSkip packages from NuGet. This guide uses CefSharp 152.0.100, which ships Chromium 152.
- The Visual C++ 2022 Redistributable for x64. CefSharp’s own DLLs link against it, and the CefSharp README lists VC++ 2022 as required from version 138.
- An address for the solver. In Local mode CapSkip answers on 127.0.0.1 for that device only. In Server mode it listens on your network address or public IP, so an app on another machine can call it over the API. Both are set under connection settings.
Step 1: create a project with a Windows target and a runtime
Start from the console template, but do not add the packages yet. The template targets plain net10.0, or net8.0 on the .NET 8 SDK, and CefSharp’s .NET packages only support Windows targets.
# create a console app, then edit CefApp.csproj as shown below before adding packages dotnet new console -n CefApp cd CefApp
Open CefApp.csproj and replace its PropertyGroup with this one.
<PropertyGroup> <OutputType>Exe</OutputType> <!-- CefSharp's .NET packages only install into a Windows target (net8.0-windows works too) --> <TargetFramework>net10.0-windows</TargetFramework> <!-- without it, the Chromium binaries never reach bin and the app fails at startup --> <RuntimeIdentifier>win-x64</RuntimeIdentifier> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup>
Then add the two packages.
# the first restore downloads about 250 MB, most of it Chromium for win-x64 dotnet add package CefSharp.OffScreen.NETCore dotnet add package CapSkip
Both lines in that PropertyGroup prevent a real failure. Without the Windows target, dotnet add package stops with NU1202 and says the package is not compatible with net10.0, because it supports net6.0-windows7.0 and later. Without the RuntimeIdentifier, the build succeeds with no warnings, and the app then fails at startup with a FileNotFoundException for CefSharp.Core.Runtime.dll. That message misleads: the file it names is there. What is missing is libcef.dll, the Chromium engine itself, which only reaches bin when the build knows its runtime. Setting PlatformTarget to x64 alone gave the same error in our runs.
Expect a large build. The first restore downloads about 250 MB, and the output folder holds about 420 MB in 248 files, 285 MB of them in libcef.dll. CefSharp.BrowserSubprocess.exe sits next to your exe, because Chromium runs its renderers as separate processes.
Step 2: start CEF once, and stop it on the same thread
CEF starts once per process and must be shut down on the thread that started it. The CefSharp General Usage guide warns that breaking this rule hangs the app. In version 152 it throws instead. So the top-level code below stays synchronous, and only RunAsync uses await.
// Program.cs. Cef.Initialize and Cef.Shutdown must run on the same thread, so the
// top-level code stays synchronous and blocks while the async work runs
using System.Text.Json;
using CapSkip;
using CefSharp;
using CefSharp.OffScreen;
const string SiteUrl = "https://example.com/login";
var settings = new CefSettings
{
// CEF locks this folder: give each app, and each running copy, its own
RootCachePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CefApp"),
};
// InitializeAsync returns only when CEF is fully up
if (!Cef.InitializeAsync(settings).GetAwaiter().GetResult())
throw new InvalidOperationException("CEF did not start: " + Cef.GetExitCode());
try
{
RunAsync(SiteUrl).GetAwaiter().GetResult();
}
finally
{
Cef.Shutdown();
}
static async Task RunAsync(string url)
{
using var browser = new ChromiumWebBrowser(url);
// the wait has no timeout of its own: a malformed URL never finishes loading
var load = await browser.WaitForInitialLoadAsync().WaitAsync(TimeSpan.FromSeconds(30));
Console.WriteLine($"Loaded {url}: HTTP {load.HttpStatusCode}");
}Here is why. In a console app, code after an await usually resumes on a thread pool thread. With await at the top level, Cef.Shutdown ran on a thread pool thread, not on the main thread that ran Cef.Initialize. The app then died with an unhandled exception that begins: Cef.Shutdown must be called on the same thread that Cef.Initialize was called. Awaiting Cef.InitializeAsync fails the same way, because its continuation runs on CEF’s own UI thread. Blocking with GetAwaiter().GetResult() keeps Cef.Shutdown on the main thread, and the finally block still shuts CEF down when RunAsync throws.
Use InitializeAsync, even though the code waits for it synchronously. It completes only when CEF is fully up, which is also how CefSharp’s own OffScreen example starts it.
RootCachePath must be an absolute path. A relative one throws at once with a message that says so. Leave it out and CEF uses a shared default folder under local app data, which its own log warns about. Cookies and local storage stay in memory unless you also set CachePath to a folder inside RootCachePath, so a fresh run starts with no session. Startup took about 0.3 seconds in our runs, and Shutdown about 0.2.
Step 3: read the sitekey through the main frame
This is the CefSharp-specific trap. Right after LoadUrlAsync or WaitForNavigationAsync, browser.EvaluateScriptAsync threw an exception in 20 runs out of 20 on .NET 10. The message begins: Unable to execute javascript at this time, scripts can only be executed within a V8Context. The page’s JavaScript context appeared 8 to 18 milliseconds later. After WaitForInitialLoadAsync it failed far less often, but still in one or two of the first three browsers started in parallel right after CEF came up. The same script sent through browser.GetMainFrame().EvaluateScriptAsync never failed, so every script in this guide goes through the main frame.
// data-sitekey covers most pages. A grecaptcha.render() call leaves no attributes,
// but Google's anchor iframe carries the key in k= and the widget size in size=
static async Task<(string Sitekey, bool Invisible, string? Callback)?> FindRecaptchaAsync(
ChromiumWebBrowser browser)
{
var r = await browser.GetMainFrame().EvaluateScriptAsync("""
(() => {
const el = document.querySelector('.g-recaptcha[data-sitekey]');
return el && [el.dataset.sitekey, el.dataset.size ?? '', el.dataset.callback ?? ''];
})()
""");
var attrs = r.Result as List<object>;
var anchor = browser.GetBrowser().GetAllFrames()
.Select(f => f.Url)
.FirstOrDefault(u => u.Contains("/recaptcha/") && u.Contains("/anchor?"));
var query = anchor is null
? null
: System.Web.HttpUtility.ParseQueryString(new Uri(anchor).Query);
var sitekey = attrs?[0] as string ?? query?["k"];
if (sitekey is null)
return null;
var invisible = (query?["size"] ?? attrs?[1] as string) == "invisible";
var callback = attrs?[2] as string;
return (sitekey, invisible, string.IsNullOrEmpty(callback) ? null : callback);
}CefSharp hands back real .NET values, not JSON. A JavaScript string arrives as a string with no quotes around it, an array as a List of object, a whole number as an Int32, and null or undefined as null. A script that throws does not raise an exception in C#: the response has Success set to false and the JavaScript error in Message. A returned Promise is awaited for you.
A page that calls grecaptcha.render() leaves no data-sitekey attribute behind. Google’s anchor iframe still carries the key in its k parameter and the widget size in its size parameter, and GetAllFrames() lists that iframe with its address, cross-origin or not, with no JavaScript needed. Frame order changed between runs, so the code searches by address rather than by position.
Step 4: solve the CAPTCHA with CapSkip
Send the sitekey and the page address to CapSkip. An invisible widget needs the invisible option, and everything else is the default checkbox call.
// CapSkip solves the widget for this sitekey and page and returns the token.
// CapSkip.TimeoutException is written in full: System has a TimeoutException too
static async Task<string?> SolveAsync(string sitekey, string pageUrl, bool invisible)
{
var solver = new CapSkipClient(host: "127.0.0.1", port: 8080);
var options = invisible ? new Dictionary<string, object?> { ["invisible"] = 1 } : null;
try
{
var result = await solver.RecaptchaAsync(sitekey, pageUrl, options);
return result.Code;
}
catch (NetworkException ex) { Console.WriteLine("Is CapSkip running? " + ex.Message); }
catch (ApiException ex) { Console.WriteLine("CapSkip could not solve it: " + ex.Message); }
catch (CapSkip.TimeoutException) { Console.WriteLine("No answer before the timeout"); }
catch (CapSkipError ex) { Console.WriteLine("CapSkip error: " + ex.Message); }
return null;
}One name needs care. With the implicit usings of a console project, a bare catch of TimeoutException fails to compile with CS0104, because System.TimeoutException and CapSkip.TimeoutException are both in scope. Write the full CapSkip name. RecaptchaAsync gives up after the SDK’s recaptchaTimeout, 300 seconds by default, which you set in the CapSkipClient constructor. A slow solve usually fails earlier inside CapSkip itself, and then arrives as an ApiException. The await here can resume on any thread, and that is fine: unlike WebView2, CefSharp accepts script calls from any thread.
Step 5: inject the token and submit the form
Put the token where Google’s script would have put it, then trigger whatever the page does next, and wait for the page the form posts to.
// fill every response field, call the widget's callback or submit its form,
// then wait for the page the form posts to and read it
static async Task<string> SubmitTokenAsync(
ChromiumWebBrowser browser, string? callback, string token)
{
// start waiting before the submit, or the navigation can finish first
var navigation = browser.WaitForNavigationAsync(TimeSpan.FromSeconds(30));
var r = await browser.GetMainFrame().EvaluateScriptAsync($$"""
((token, cb) => {
const boxes = document.querySelectorAll('textarea[name="g-recaptcha-response"]');
boxes.forEach(b => { b.value = token; });
if (cb && typeof window[cb] === 'function') { window[cb](token); return 'called back'; }
const form = boxes[0]?.form;
if (!form) return 'no form found';
if (!form.checkValidity()) return 'form has invalid or empty fields';
form.requestSubmit();
return 'submitted';
})({{JsonSerializer.Serialize(token)}}, {{JsonSerializer.Serialize(callback)}})
""");
var how = r.Result as string ?? r.Message;
if (how is not ("submitted" or "called back"))
return how;
try
{
var page = await navigation;
var text = await browser.GetMainFrame().EvaluateScriptAsync("document.body.innerText");
return $"{how}, HTTP {page.HttpStatusCode}: {text.Result}";
}
catch (System.TimeoutException) { return how + ", but the page did not navigate"; }
}Fill every g-recaptcha-response textarea, not just the first. A form with two widgets, or with its own field beside Google’s, has two fields with that name, and a server such as PHP keeps only the last one. If the widget has a data-callback attribute, the script calls that function with the token, because the page expects it. Otherwise it calls form.requestSubmit(), which runs the page’s own submit handlers and HTML5 validation like a click on the button. If a required field is empty, requestSubmit() posts nothing, so without the checkValidity line the app would wait the full 30 seconds and report that the page did not navigate. The check names the real cause at once.
Start WaitForNavigationAsync before the submit script, not after it. Started 50 milliseconds after the submit, the wait missed the navigation and timed out. Started before it, the wait finished 10 to 30 milliseconds after the submit script returned. WaitForNavigationAsync waits five seconds by default, so the code passes 30. When that time runs out it throws System.TimeoutException, which is why that catch names System in full. The token goes into the script as JSON string literals built with JsonSerializer.Serialize, so quotes in it cannot break the script. Do not pass it as a second argument to EvaluateScriptAsync instead: that overload reads the first string as the name of a function to call.
Inject the token straight away. Google documents in its reCAPTCHA verification guide that a token expires two minutes after it is issued. In our runs the form was posted 16 to 21 milliseconds after CapSkip returned. The full example below calls these three functions from RunAsync.
The full console example
Replace Program.cs with the code below, change SiteUrl to your page and run it with dotnet run.
// Program.cs: load a page in a windowless CefSharp browser, solve its reCAPTCHA v2
// with CapSkip, submit the form and print the page that comes back
using System.Text.Json;
using CapSkip;
using CefSharp;
using CefSharp.OffScreen;
const string SiteUrl = "https://example.com/login";
var settings = new CefSettings
{
// CEF locks this folder: give each app, and each running copy, its own
RootCachePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CefApp"),
};
// InitializeAsync returns only when CEF is fully up
if (!Cef.InitializeAsync(settings).GetAwaiter().GetResult())
throw new InvalidOperationException("CEF did not start: " + Cef.GetExitCode());
try
{
RunAsync(SiteUrl).GetAwaiter().GetResult();
}
finally
{
Cef.Shutdown();
}
static async Task RunAsync(string url)
{
using var browser = new ChromiumWebBrowser(url);
// the wait has no timeout of its own: a malformed URL never finishes loading
var load = await browser.WaitForInitialLoadAsync().WaitAsync(TimeSpan.FromSeconds(30));
Console.WriteLine($"Loaded {url}: HTTP {load.HttpStatusCode}");
if (await FindRecaptchaAsync(browser) is not { } widget)
{
Console.WriteLine("No reCAPTCHA on this page");
return;
}
Console.WriteLine($"Sitekey {widget.Sitekey}, invisible: {widget.Invisible}");
var token = await SolveAsync(widget.Sitekey, browser.Address, widget.Invisible);
if (token is null)
return;
Console.WriteLine($"Token received, {token.Length} characters");
Console.WriteLine(await SubmitTokenAsync(browser, widget.Callback, token));
}
// data-sitekey covers most pages. A grecaptcha.render() call leaves no attributes,
// but Google's anchor iframe carries the key in k= and the widget size in size=
static async Task<(string Sitekey, bool Invisible, string? Callback)?> FindRecaptchaAsync(
ChromiumWebBrowser browser)
{
var r = await browser.GetMainFrame().EvaluateScriptAsync("""
(() => {
const el = document.querySelector('.g-recaptcha[data-sitekey]');
return el && [el.dataset.sitekey, el.dataset.size ?? '', el.dataset.callback ?? ''];
})()
""");
var attrs = r.Result as List<object>;
var anchor = browser.GetBrowser().GetAllFrames()
.Select(f => f.Url)
.FirstOrDefault(u => u.Contains("/recaptcha/") && u.Contains("/anchor?"));
var query = anchor is null
? null
: System.Web.HttpUtility.ParseQueryString(new Uri(anchor).Query);
var sitekey = attrs?[0] as string ?? query?["k"];
if (sitekey is null)
return null;
var invisible = (query?["size"] ?? attrs?[1] as string) == "invisible";
var callback = attrs?[2] as string;
return (sitekey, invisible, string.IsNullOrEmpty(callback) ? null : callback);
}
// CapSkip solves the widget for this sitekey and page and returns the token.
// CapSkip.TimeoutException is written in full: System has a TimeoutException too
static async Task<string?> SolveAsync(string sitekey, string pageUrl, bool invisible)
{
var solver = new CapSkipClient(host: "127.0.0.1", port: 8080);
var options = invisible ? new Dictionary<string, object?> { ["invisible"] = 1 } : null;
try
{
var result = await solver.RecaptchaAsync(sitekey, pageUrl, options);
return result.Code;
}
catch (NetworkException ex) { Console.WriteLine("Is CapSkip running? " + ex.Message); }
catch (ApiException ex) { Console.WriteLine("CapSkip could not solve it: " + ex.Message); }
catch (CapSkip.TimeoutException) { Console.WriteLine("No answer before the timeout"); }
catch (CapSkipError ex) { Console.WriteLine("CapSkip error: " + ex.Message); }
return null;
}
// fill every response field, call the widget's callback or submit its form,
// then wait for the page the form posts to and read it
static async Task<string> SubmitTokenAsync(
ChromiumWebBrowser browser, string? callback, string token)
{
// start waiting before the submit, or the navigation can finish first
var navigation = browser.WaitForNavigationAsync(TimeSpan.FromSeconds(30));
var r = await browser.GetMainFrame().EvaluateScriptAsync($$"""
((token, cb) => {
const boxes = document.querySelectorAll('textarea[name="g-recaptcha-response"]');
boxes.forEach(b => { b.value = token; });
if (cb && typeof window[cb] === 'function') { window[cb](token); return 'called back'; }
const form = boxes[0]?.form;
if (!form) return 'no form found';
if (!form.checkValidity()) return 'form has invalid or empty fields';
form.requestSubmit();
return 'submitted';
})({{JsonSerializer.Serialize(token)}}, {{JsonSerializer.Serialize(callback)}})
""");
var how = r.Result as string ?? r.Message;
if (how is not ("submitted" or "called back"))
return how;
try
{
var page = await navigation;
var text = await browser.GetMainFrame().EvaluateScriptAsync("document.body.innerText");
return $"{how}, HTTP {page.HttpStatusCode}: {text.Result}";
}
catch (System.TimeoutException) { return how + ", but the page did not navigate"; }
}The app prints each stage: the HTTP status of the page, the sitekey and whether the widget is invisible, the length of the token, and the text of the page the form posted to. On a page with no widget it says so and stops. We ran it on .NET 8 and .NET 10 against nine pages. They were a checkbox with its own textarea, the same checkbox with Google’s real api.js and its public test key, and two invisible widgets with a callback, one with Google’s script and one without. The rest were explicit renders with and without the invisible size, two response fields, an empty required field, and a page with no widget. It also reports a solver that is not running, about two seconds after the first attempt to reach it.
Most of a run is waiting. With Google’s script on the page, the solve request reached CapSkip 0.3 to 1.3 seconds after the page was requested, the longest being the first visit after the cache was cleared. Most of that is api.js and its iframe loading. Everything after the solve took a few dozen milliseconds.
Two waits have no timeout of their own. WaitForInitialLoadAsync and LoadUrlAsync never give up, and a malformed address never finishes loading: an unguarded test was still waiting when we stopped it after two and a half minutes. That is why RunAsync wraps the first load in WaitAsync. A load that fails still completes, with Success set to false. A 404 page reports its status code, and a closed port reports ConnectionRefused after about two seconds.
Several pages at once
One Cef.Initialize can serve many browsers, and each solve can run at the same time. Give every browser its own RequestContext, though. Without one, all browsers share one cookie jar, so each login page overwrites the session cookie of the others.
// several pages at once: one Cef.Initialize, one browser per page. Each browser gets
// its own RequestContext, or they all share one cookie jar and one session
static Task RunManyAsync(string[] urls) =>
Task.WhenAll(urls.Select(async url =>
{
using var browser = new ChromiumWebBrowser(url, requestContext: new RequestContext());
await browser.WaitForInitialLoadAsync().WaitAsync(TimeSpan.FromSeconds(30));
if (await FindRecaptchaAsync(browser) is not { } widget)
return;
var token = await SolveAsync(widget.Sitekey, browser.Address, widget.Invisible);
if (token is not null)
Console.WriteLine(url + ": " + await SubmitTokenAsync(browser, widget.Callback, token));
}));Three browsers on the shared context solved in parallel, and two of their three posts were rejected for a CSRF mismatch, because the cookie no longer matched the form. With a RequestContext each, all three were accepted. Call RunManyAsync from the top level in place of RunAsync, inside the same try and finally. The compiler then warns that RunAsync and SiteUrl are unused, which is harmless. CapSkip’s reCAPTCHA section runs 10 solves at a time by default, set by Max. Threads, and queues the rest.
Two copies of the app cannot share one RootCachePath. The second copy failed to start within about 0.1 seconds, so the code above throws its InvalidOperationException, with the message CEF did not start: NormalExitProcessNotified. Worse, the copy that was already running crashed later, at Cef.Shutdown, in eight runs out of eight. Give each running copy its own folder. If a second start can happen anyway, add this handler, which stopped the crash in every run.
// at the very end of Program.cs, then start CEF with
// Cef.InitializeAsync(settings, browserProcessHandler: new RelaunchHandler()).
// A second copy on the same RootCachePath still cannot start, but CEF no longer opens
// a window of its own in the running copy
class RelaunchHandler : CefSharp.Handler.BrowserProcessHandler
{
protected override bool OnAlreadyRunningAppRelaunch(
IReadOnlyDictionary<string, string> commandLine, string currentDirectory) => true;
}What changes in WinForms or WPF
To solve reCAPTCHA in CefSharp inside a desktop app, use the CefSharp.WinForms.NETCore or CefSharp.Wpf.NETCore package and the same steps. The control starts and stops CEF itself, so you do not call Cef.Shutdown. If you want your own settings, call Cef.Initialize with them before the first control is created. Scripts, frames and the CapSkip call work exactly as above. What changes is where the events arrive.
// WinForms (CefSharp.WinForms.NETCore): CefSharp raises LoadingStateChanged, FrameLoadEnd
// and AddressChanged on its own thread, not the form's, so marshal control updates back
browser.LoadingStateChanged += (_, e) =>
{
if (e.IsLoading)
return;
statusLabel.BeginInvoke(() => statusLabel.Text = "Loaded " + browser.Address);
};CefSharp raises LoadingStateChanged, FrameLoadEnd and AddressChanged on its own thread, not on the thread that owns your form. The script calls themselves are not tied to a thread: EvaluateScriptAsync worked from the UI thread, after ConfigureAwait(false) and inside Task.Run. That is the opposite of WebView2, where any call from another thread throws. So the rule in CefSharp is about your own controls. In WinForms, setting a Label from the event threw Cross-thread operation not valid only with the debugger attached, because WinForms checks cross-thread calls only then. Without a debugger the text changed and nothing was reported, so a release build hides the bug, and the form can still misbehave later. BeginInvoke runs the update on the form’s thread. In WPF the same update always throws, with the message The calling thread cannot access this object because a different thread owns it. There, use the control’s Dispatcher.InvokeAsync.
Running CapSkip on another machine
The app and the solver do not have to share a PC. Switch CapSkip to Server mode under connection settings, and it listens on your network address or public IP. Then point the client there.
// CapSkip in Server mode on another Windows machine: replace the client line in SolveAsync var solver = new CapSkipClient(apiKey: "YOUR_API_KEY", host: "192.168.1.50", port: 8080);
Use a static public IP if the route crosses the internet, and turn on API key validation, with the same key in the client. CapSkip opens its port to every address when Server mode starts, so to keep it to the machines you expect, add a Windows Firewall block rule for the others. On the same machine, write 127.0.0.1 rather than localhost, which tried the IPv6 address first and cost about two seconds per new connection in an earlier test. The solver is still your own Windows machine, and solves are still unmetered.
Common errors when you solve reCAPTCHA in CefSharp
| What you see | Cause | Fix |
|---|---|---|
| NU1202 when adding the package | The project targets net10.0 without the Windows suffix | Set the target to net10.0-windows or net8.0-windows |
| FileNotFoundException for CefSharp.Core.Runtime.dll | No RuntimeIdentifier, so libcef.dll never reached bin | Add RuntimeIdentifier win-x64 and rebuild |
| Cef.Shutdown must be called on the same thread | An await at the top level moved the code to another thread | Keep the top level synchronous and block on RunAsync |
| Unable to execute javascript at this time | The script ran on the browser before the new page had a JavaScript context | Run it through browser.GetMainFrame() |
| The app never finishes loading | The address is malformed, and the load wait has no timeout | Wrap the wait in WaitAsync with a timeout |
| The post is accepted for one browser but rejected for the others | Parallel browsers share one cookie jar | Give each browser its own RequestContext |
| The page reloads the login form after the post | Only one response field was filled, the callback never ran, or the token expired | Fill every field, call the callback, and inject right after the solve |
| Is CapSkip running? No connection could be made | CapSkip is not running, or the host or port is wrong | Start CapSkip and check the address against its connection settings |
FAQ
Does this work for reCAPTCHA v3 and Enterprise?
Yes, with options rather than a different method. An Enterprise page loads enterprise.js in place of api.js, and the anchor address then contains /recaptcha/enterprise/, which the frame search above already matches. Add enterprise set to 1 to the options of RecaptchaAsync. A v3 page has no checkbox and no textarea inside the form: its sitekey sits in the render parameter of api.js, and the page sends the token from its own script. Its anchor iframe says size=invisible, so FindRecaptchaAsync reports it as an invisible widget. Check the api.js address for render= first, then pass version set to v3 and the page’s action, and leave out invisible, which the SDK refuses for v3. The invisible and enterprise options are covered in more detail in this guide to solving reCAPTCHA v2 in C#.
Does an OffScreen browser look headless to the site?
Not by the obvious signs. Its user agent is an ordinary Chrome 152 string with no HeadlessChrome in it, navigator.webdriver is false, and the default view is 1366 by 768 pixels. Two differences are visible. The browser brands it sends name Chromium but not Google Chrome, and the window’s outer size equals its inner size, because no browser frame surrounds the page. The reCAPTCHA token itself comes from CapSkip’s solve, not from this browser, and our tests used Google’s test key, so we have no measurement of how a site scores the CefSharp browser.
Can I skip Cef.Shutdown?
An app that left it out exited normally in our runs, about 0.1 seconds after Main returned. CefSharp still documents it as required, and the crashes we saw had other causes: Shutdown on the wrong thread, or a second copy of the app on the same cache folder. Keep it in the finally block, on the main thread, after InitializeAsync has finished.
Can the app run on a different machine from CapSkip?
Yes. Put CapSkip in Server mode under connection settings, so it listens on a network address instead of loopback, and pass that address as the host of CapSkipClient. The app then calls it over the same HTTP API from any machine that can reach the port. Add a Windows Firewall block rule for the addresses that should not reach it. The solver still runs on hardware you own, and no solve goes through a third-party service.
The short version
To solve reCAPTCHA in CefSharp, target net10.0-windows or net8.0-windows with a RuntimeIdentifier, start CEF with InitializeAsync and keep the top level synchronous so Cef.Shutdown runs on the same thread. Run every script through GetMainFrame(), read the sitekey from data-sitekey or the anchor iframe, and solve it with RecaptchaAsync. Fill every response field, call the callback or form.requestSubmit(), and start waiting for the navigation before you submit.
- Every CAPTCHA type the .NET package handles: the C# CAPTCHA solver page.
- How the checkbox reCAPTCHA works and what the solver covers: the reCAPTCHA v2 solver page.
A browser that runs on your own Windows PC deserves a solver that does too. CapSkip is an unlimited captcha solver on that same machine, so a batch of pages costs no more to solve than a single one.
