How to Solve reCAPTCHA in WebView2 With C# (WinForms, WPF)

To solve reCAPTCHA in WebView2, wait for NavigationCompleted, read the sitekey with ExecuteScriptAsync, and send it to CapSkip with a plain await so your code comes back on the UI thread. Then write the token into every g-recaptcha-response field and call the page’s callback or submit the form. Two details cause most failures. ExecuteScriptAsync returns JSON, not a string, so the sitekey arrives wrapped in quotes. And one ConfigureAwait(false) on the solve makes the next WebView2 call throw an InvalidOperationException, because CoreWebView2 only answers on the thread that created it. This guide builds a small WinForms app that handles both, plus invisible widgets, explicit renders and closing the window mid-solve.
What you need
- CapSkip running on a Windows machine, with the reCAPTCHA solver switched on.
- The .NET 8 SDK or later. The code below was run on .NET 8 and .NET 10, built with SDK 10.0.100.
- The WebView2 Runtime. Windows 11 ships with it, and it updates itself automatically, separately from the Edge browser.
- The Microsoft.Web.WebView2 and CapSkip packages from NuGet.
- 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.
dotnet new winforms -n WvApp cd WvApp dotnet add package Microsoft.Web.WebView2 dotnet add package CapSkip
Both packages restore cleanly for net8.0-windows and net10.0-windows. A WinForms build prints one harmless MSB3277 warning about conflicting versions of WindowsBase, because the WebView2 package always references its WPF assembly too. It does not affect the app, and a WPF project never shows it. Setting UseWPF to true also hides it, but then System.IO leaves the implicit usings and Path.Combine stops compiling.
Step 1: give WebView2 a data folder it can write
WebView2 keeps cookies, cache and local storage in a user data folder. If you do not name one, it creates it next to your exe, as WvApp.exe.WebView2. That works while you run the app from bin\Debug. Once the app is installed somewhere a standard user cannot write, such as Program Files, initialisation fails at once with an UnauthorizedAccessException, Access is denied. Name a folder under the user’s local app data instead.
async Task StartAsync()
{
var dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WvApp");
var env = await CoreWebView2Environment.CreateAsync(null, dataDir);
await webView.EnsureCoreWebView2Async(env);
webView.CoreWebView2.NavigationCompleted += OnNavigationCompleted;
webView.CoreWebView2.Navigate(LoginUrl);
}Create the environment before anything else touches the control. Setting the Source property first starts initialisation with the default environment, and a later EnsureCoreWebView2Async with your own environment then throws an ArgumentException saying WebView2 was already initialized with a different CoreWebView2Environment. Calling ExecuteScriptAsync on the control before initialisation fails too, with an InvalidOperationException that points you to EnsureCoreWebView2Async. Through webView.CoreWebView2, as in this guide’s code, the same mistake is a NullReferenceException, because CoreWebView2 is still null. Microsoft’s page on the WebView2 user data folder covers where the folder may live.
Step 2: read the sitekey, and decode what ExecuteScriptAsync returns
The sitekey is in the page, so read it with JavaScript. A checkbox or invisible widget carries it in a data-sitekey attribute. A page that renders the widget itself with grecaptcha.render has no such attribute, but Google’s anchor iframe carries the same key in its k query parameter, so the script falls back to that.
// data-sitekey first, then the anchor iframe that an explicit render leaves
// behind (api2 for reCAPTCHA, enterprise for reCAPTCHA Enterprise)
const string FindSitekeyJs = """
(() => {
const el = document.querySelector('.g-recaptcha[data-sitekey]');
if (el) return el.getAttribute('data-sitekey');
const f = document.querySelector('iframe[src*="/recaptcha/"][src*="/anchor?"]');
return f ? new URL(f.src).searchParams.get('k') : null;
})()
""";
async Task<string?> EvalStringAsync(string js) =>
JsonSerializer.Deserialize<string>(await webView.CoreWebView2.ExecuteScriptAsync(js));ExecuteScriptAsync hands back the script’s result serialised as JSON. A sitekey arrives with its quotes, and a missing element arrives as the four letters null. A script that throws, for example a TypeError on an element that is not there, also returns null, with no exception on the C# side. Deserialising with System.Text.Json gives you a real string or a real null. Trimming the quotes off by hand looks the same until a value contains an escaped character, and then it breaks.
Run this after NavigationCompleted, not DOMContentLoaded. Google’s api.js loads asynchronously, so at DOMContentLoaded the widget’s div exists but the response textarea, the grecaptcha object and the anchor iframe may not. On a first visit they appeared between 0.3 and 1.4 seconds later in our runs, always before NavigationCompleted. Once the script was cached, they were already there at DOMContentLoaded. So code that reads too early can work on your machine and fail for a new user.
Step 3: solve the CAPTCHA without leaving the UI thread
This is the rule that breaks the most attempts to solve reCAPTCHA in WebView2. CoreWebView2 belongs to the UI thread that created it, and touching it from any other thread throws. Guides for library code say to add ConfigureAwait(false) to every await. In a WebView2 event handler, that advice is wrong.
// In OnNavigationCompleted, where core is webView.CoreWebView2.
// Throws: ConfigureAwait(false) resumes on a thread-pool thread
var result = await solver.RecaptchaAsync(sitekey, core.Source).ConfigureAwait(false);
await webView.CoreWebView2.ExecuteScriptAsync("document.title");
// System.InvalidOperationException: CoreWebView2 can only be accessed from the UI thread.ConfigureAwait(false) lets the code after the solve resume on a thread pool thread. The next call to CoreWebView2 then fails with that InvalidOperationException. The same happens if you wrap the script call in Task.Run. In WPF the message differs, The calling thread cannot access this object because a different thread owns it, and if it escapes an async void handler it crashes the app. Read Microsoft’s notes on the WebView2 threading model for the full rule.
// Works: a plain await inside a WinForms event handler comes back on the UI
// thread
var result = await solver.RecaptchaAsync(sitekey, core.Source);
await webView.CoreWebView2.ExecuteScriptAsync("document.title");A plain await inside a WinForms or WPF event handler captures the UI’s synchronisation context, so the code after it runs on the UI thread again. The UI stays responsive while CapSkip works: a 100 millisecond timer ticked 71 times during a 7.88 second solve in our test. Blocking with GetAwaiter().GetResult() does not deadlock, because the CapSkip SDK uses ConfigureAwait(false) inside, but it freezes the window for the whole solve.
Step 4: inject the token and submit the form
Once CapSkip returns, put the token where Google’s script would have put it, then trigger whatever the page does next.
// fill every response textarea, then call data-callback or submit the form
const string InjectJs = """
(token => {
const boxes = document.querySelectorAll('textarea[name="g-recaptcha-response"]');
boxes.forEach(b => { b.value = token; });
const cb = document.querySelector('.g-recaptcha[data-callback]')?.dataset.callback;
if (cb && typeof window[cb] === 'function') { window[cb](token); return 'called ' + cb; }
const form = boxes[0]?.form;
if (!form) return 'no form found';
if (!form.checkValidity()) return 'form has empty required fields';
form.requestSubmit();
return 'submitted the form';
})
""";Fill every g-recaptcha-response textarea, not just the first. A form with two widgets, or with its own textarea beside Google’s, has two fields with that name, and a server such as PHP keeps only the last one. On our test page with two fields, setting only the first was rejected, while setting both was accepted.
A widget with a data-callback attribute expects the page’s callback to run with the token, so the script calls it. On our callback page, submitting the form without the callback was rejected. A page that renders the widget with grecaptcha.render passes its callback in JavaScript instead, where the script cannot see it. Find the function name in the page’s own script and call it with the token in the same way.
Otherwise the script submits the form with form.requestSubmit(), which runs the page’s own submit handlers and HTML5 validation, like a click on its button. One difference remains: the button’s own name and value are sent only if you pass the button, as in form.requestSubmit(button). The older form.submit() method skips the handlers and the validation, so a required field left empty goes to the server anyway. The checkValidity line reports that case instead of posting a half-empty form.
The token is passed in as a JSON string literal, built with JsonSerializer.Serialize, so quotes or backslashes in it can never break the script. Inject it straight away. Google documents that a reCAPTCHA token expires two minutes after it is issued.
The full WinForms example
Create the project with the name WvApp, so this file shares a namespace with the Form1.Designer.cs the template made. Replace Form1.cs with the code below, change LoginUrl to your page and run it with dotnet run. Program.cs stays as the template wrote it.
// Form1.cs, in a project made with: dotnet new winforms -n WvApp
using System.Text.Json;
using CapSkip;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
namespace WvApp;
public partial class Form1 : Form
{
const string LoginUrl = "https://example.com/login";
// data-sitekey first, then the anchor iframe that an explicit render leaves
// behind (api2 for reCAPTCHA, enterprise for reCAPTCHA Enterprise)
const string FindSitekeyJs = """
(() => {
const el = document.querySelector('.g-recaptcha[data-sitekey]');
if (el) return el.getAttribute('data-sitekey');
const f = document.querySelector('iframe[src*="/recaptcha/"][src*="/anchor?"]');
return f ? new URL(f.src).searchParams.get('k') : null;
})()
""";
const string IsInvisibleJs = """
(() => {
const f = document.querySelector('iframe[src*="/recaptcha/"][src*="/anchor?"]');
if (f) return new URL(f.src).searchParams.get('size') === 'invisible';
const el = document.querySelector('.g-recaptcha[data-sitekey]');
if (!el) return false;
if (el.getAttribute('data-size') === 'invisible') return true;
return el.tagName === 'BUTTON' && !el.hasAttribute('data-action');
})()
""";
// fill every response textarea, then call data-callback or submit the form
const string InjectJs = """
(token => {
const boxes = document.querySelectorAll('textarea[name="g-recaptcha-response"]');
boxes.forEach(b => { b.value = token; });
const cb = document.querySelector('.g-recaptcha[data-callback]')?.dataset.callback;
if (cb && typeof window[cb] === 'function') { window[cb](token); return 'called ' + cb; }
const form = boxes[0]?.form;
if (!form) return 'no form found';
if (!form.checkValidity()) return 'form has empty required fields';
form.requestSubmit();
return 'submitted the form';
})
""";
readonly WebView2 webView = new() { Dock = DockStyle.Fill };
readonly CapSkipClient solver = new(host: "127.0.0.1", port: 8080);
readonly CancellationTokenSource cts = new();
public Form1()
{
InitializeComponent();
Controls.Add(webView);
Load += async (_, _) => await StartAsync();
FormClosing += (_, _) => cts.Cancel();
}
async Task StartAsync()
{
var dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WvApp");
var env = await CoreWebView2Environment.CreateAsync(null, dataDir);
await webView.EnsureCoreWebView2Async(env);
webView.CoreWebView2.NavigationCompleted += OnNavigationCompleted;
webView.CoreWebView2.Navigate(LoginUrl);
}
async Task<string?> EvalStringAsync(string js) =>
JsonSerializer.Deserialize<string>(await webView.CoreWebView2.ExecuteScriptAsync(js));
async void OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
{
var core = webView.CoreWebView2;
var sitekey = await EvalStringAsync(FindSitekeyJs);
if (sitekey is null) return; // no widget on this page
core.NavigationCompleted -= OnNavigationCompleted; // solve once, not on the result page
var invisible = await core.ExecuteScriptAsync(IsInvisibleJs) == "true";
var options = invisible ? new Dictionary<string, object?> { ["invisible"] = 1 } : null;
Text = "Solving reCAPTCHA...";
try
{
var result = await solver.RecaptchaAsync(sitekey, core.Source, options, cts.Token);
var token = JsonSerializer.Serialize(result.Code);
Text = "reCAPTCHA solved, " + await EvalStringAsync($"({InjectJs})({token})");
}
catch (Exception) when (cts.IsCancellationRequested) { } // window closed mid-solve
catch (CapSkipError ex) { Text = "CapSkip error: " + ex.Message; }
}
}The handler removes itself as soon as it finds a sitekey. NavigationCompleted fires again for the page the form posts to, and without that line the app would try to solve the result page as well. On a page with no widget the handler returns early and stays attached, so it checks again after the next navigation. A widget that appears later on the same page needs the polling loop shown further down. An invisible widget is detected from the size parameter of Google’s anchor iframe. Before that iframe exists, a data-size attribute or a widget bound to a button marks it. It is then sent to CapSkip with the invisible option. The window title shows the status. That is enough for a tool, and a Label can show it instead.
The CancellationTokenSource is cancelled when the window starts to close. Close the app in the middle of a solve and the SDK stops polling at once with a TaskCanceledException, which the first catch swallows. Without the token, a solve on a secondary form keeps going after the form is gone. When it returns, CoreWebView2 is null, and the next script call throws a NullReferenceException that WinForms shows in its unhandled exception dialog.
The same code in WPF
To solve reCAPTCHA in WebView2 under WPF, keep the steps above. The WPF control has the same CoreWebView2 underneath, so the WebView2 and CapSkip calls do not change. Declare the control in MainWindow.xaml with the Microsoft.Web.WebView2.Wpf namespace and name it webView, then remove the webView field and the Controls.Add line. Move the rest into MainWindow.xaml.cs, start it from the Loaded event and cancel the token in the Closing event handler. A WPF window has no Text property, so write the status to Title instead. WPF’s implicit usings leave out System.IO, so Path.Combine needs a using line of its own. Remove the template’s System.Windows.Shapes import from that file, because it has a Path class of its own and the name becomes ambiguous.
Widgets inside iframes, and widgets that load late
ExecuteScriptAsync only runs in the top document. If the login form itself sits in an iframe, the sitekey script returns null there. Collect the page’s frames with the FrameCreated event, which needs to be wired before you navigate, and drop each frame when it is destroyed. A frame that is gone throws an InvalidOperationException if you call it.
// a field: the page's iframes, filled before Navigate() and pruned as they go
readonly List<CoreWebView2Frame> frames = new();
// in StartAsync, before Navigate():
webView.CoreWebView2.FrameCreated += (_, e) =>
{
var frame = e.Frame; // read it once: each read returns a new wrapper
frames.Add(frame);
frame.Destroyed += (_, _) => frames.Remove(frame);
};Each CoreWebView2Frame has its own ExecuteScriptAsync, so the same scripts run inside the frame. Send CapSkip the frame’s own address as the page URL, since that is the page the widget lives on. Call this method from OnNavigationCompleted when the top document returns no sitekey, inside the same try and catch blocks as the main solve. FrameCreated also fires for Google’s own anchor and challenge iframes, and the loop skips them, because the script returns null inside them.
// widget inside a cross-origin iframe: run the same scripts in that frame
async Task SolveInFramesAsync()
{
foreach (var frame in frames.ToList())
{
var key = JsonSerializer.Deserialize<string>(await frame.ExecuteScriptAsync(FindSitekeyJs));
if (key is null) continue;
var href = JsonSerializer.Deserialize<string>(await frame.ExecuteScriptAsync("location.href"))!;
var result = await solver.RecaptchaAsync(key, href, null, cts.Token);
var token = JsonSerializer.Serialize(result.Code);
await frame.ExecuteScriptAsync($"({InjectJs})({token})");
return;
}
}Some pages add the widget after the load event, for example after a cookie banner or a first click. NavigationCompleted then finds nothing. A short polling loop handles it: on a page that added its widget two seconds after load, this found the key after 2.11 seconds.
// for pages that add the widget after the load event
async Task<string?> WaitForSitekeyAsync(TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var key = await EvalStringAsync(FindSitekeyJs);
if (key is not null) return key;
await Task.Delay(250);
}
return null;
}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 on your network 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. In our runs localhost tried the IPv6 address first and waited about two seconds for it to fail on every new connection. A solver that was not running took four seconds to report instead of two.
Handling CapSkip errors in the app
The SDK raises four exceptions, all derived from CapSkipError. To show a different message for each failure, replace the try block in OnNavigationCompleted with this one. Keep the cancellation filter first: a window closed while the task is being sent raises a NetworkException, and a later filter would show it as an error.
try
{
var result = await solver.RecaptchaAsync(sitekey, core.Source, options, cts.Token);
var token = JsonSerializer.Serialize(result.Code);
Text = "reCAPTCHA solved, " + await EvalStringAsync($"({InjectJs})({token})");
}
catch (Exception) when (cts.IsCancellationRequested) { } // window closed mid-solve
catch (NetworkException ex) { Text = "Is CapSkip running? " + ex.Message; }
catch (ApiException ex) { Text = "CapSkip could not solve it: " + ex.Message; }
catch (CapSkip.TimeoutException) { Text = "No answer before the timeout"; }
catch (CapSkipError ex) { Text = "CapSkip error: " + ex.Message; }One name needs care. With the implicit usings of a WinForms 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, as above. 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.
Common errors when you solve reCAPTCHA in WebView2
| What you see | Cause | Fix |
|---|---|---|
| InvalidOperationException: CoreWebView2 can only be accessed from the UI thread | The code after the solve resumed on a thread pool thread, through ConfigureAwait(false) or Task.Run | Use a plain await in the event handler |
| The sitekey has quotes around it and every solve fails | The raw JSON from ExecuteScriptAsync went to CapSkip | Deserialise the result with System.Text.Json |
| The sitekey is null on a page with a visible widget | The page renders the widget itself, or adds it late, or it sits in an iframe | Read the anchor iframe, poll for the key, or run the script in the frame |
| Access is denied as soon as the app starts | WebView2 tried to create its data folder next to an exe the user cannot write to | Create the environment with a folder under local app data |
| ArgumentException: already initialized with a different environment | Source was set before your environment was created | Initialise with your environment first, then navigate |
| 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 |
| The app tries to solve the page it lands on after login | NavigationCompleted fires for every navigation | Remove the handler once a sitekey is found |
| NetworkException: the target machine actively refused it | CapSkip is not running, or the host or port is wrong | Start CapSkip and check the address against its connection settings |
| A NullReferenceException dialog after closing a window | A solve finished after its form was closed | Pass a CancellationToken and cancel it when the form closes |
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. Add enterprise set to 1 to the options of RecaptchaAsync, and the rest of this guide stays the same. A v3 page is different: its sitekey sits in the render parameter of api.js, and the page sends the token from its own script, not from a textarea. Pass version set to v3 and the page’s action, and leave out invisible, which the SDK refuses for v3. The guide to solving reCAPTCHA v2 in C# covers the options in more detail.
How can I tell a script error from a real null?
Use ExecuteScriptWithResultAsync, which the current WebView2 package includes. It reports whether the script succeeded and gives you the exception message when it did not, where ExecuteScriptAsync returns null for both cases. Keep ExecuteScriptAsync for the simple reads above, where a missing widget and a broken selector mean the same thing: nothing to solve.
Is this the same as solving CAPTCHAs in a mobile WebView?
No. WebView2 is the Edge engine inside a Windows desktop app, and you control it directly from C#. A WebView inside an Android or iOS app is driven from the outside, usually through Appium, which has its own session timeouts. That case is covered in the guide to solving CAPTCHAs in Appium.
Can the desktop 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 WebView2, read the sitekey after NavigationCompleted and deserialise the JSON that ExecuteScriptAsync returns. Await RecaptchaAsync without ConfigureAwait(false), so the next CoreWebView2 call happens on the UI thread. Fill every response field, then call the page’s callback or use form.requestSubmit(). Give WebView2 a data folder it can write, cancel the solve when the window closes, and switch CapSkip to Server mode when the app moves to another machine.
- 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 desktop app and its solver on the same Windows PC is the simplest setup there is. That is what a local captcha solver is for: the token comes from your own machine, and no solve leaves it.
