How to Solve CAPTCHAs in a .NET Worker Service That Stays Up

worker service captcha - How to Solve CAPTCHAs in a .NET Worker Service That Stays Up

To solve CAPTCHAs in a .NET Worker Service, register one CapSkipClient as a singleton, call RecaptchaAsync from ExecuteAsync with the stopping token, and catch CapSkip’s exceptions inside the loop. That last part is what the worker template leaves out, and it matters more than it looks. Since .NET 6, an unhandled exception in a BackgroundService stops the whole host. The process then exits with code 0, so the recovery options of a Windows Service never restart it. A single NetworkException at boot, thrown because CapSkip was not open yet, is enough. This worker service CAPTCHA setup logs in to a site behind reCAPTCHA every 15 minutes, waits out a solver that is not up yet, stops in a fraction of a second and runs as a Windows Service next to CapSkip.

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.
  • The CapSkip package from NuGet, plus Microsoft.Extensions.Hosting.WindowsServices, which Program.cs below uses to run as a Windows Service.
  • Administrator rights on the machine where you install the service, and only for that step.
  • 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 a worker on another machine can call it over the API. Both are set under connection settings.
dotnet new worker -n PortalWorker
cd PortalWorker
dotnet add package CapSkip
# Update Hosting before the next line: the template pins an older version,
# and the Windows Services package fails to restore against it (NU1605).
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Hosting.WindowsServices

The order of those last two lines matters. The worker template pins the version of Microsoft.Extensions.Hosting that shipped with your SDK, and the current Windows Services package needs a newer one. Add the Windows Services package first and the restore fails with NU1605, a package downgrade error. Updating the Hosting package first avoids it, and nothing else in the template changes.

Step 1: register one client in Program.cs

CapSkipClient holds no per-request state and sends everything through one static HttpClient inside the package, so a single instance can serve the whole process. Build it once from configuration and register it as a singleton.

// Program.cs
using CapSkip;
using PortalWorker;

var builder = Host.CreateApplicationBuilder(args);

// A Windows Service when the service manager starts it, an ordinary
// console app when you run it yourself.
builder.Services.AddWindowsService(options => options.ServiceName = "PortalWorker");

// One client for the whole process. The SDK reads no environment
// variables, but the host's configuration does, so CAPSKIP_HOST can come
// from the environment or from appsettings.json.
var config = builder.Configuration;
builder.Services.AddSingleton(new CapSkipClient(
    apiKey: config["CAPSKIP_API_KEY"] ?? "capskip",
    host: config["CAPSKIP_HOST"] ?? "127.0.0.1",
    port: int.Parse(config["CAPSKIP_PORT"] ?? "8080")));
builder.Services.AddSingleton<PortalLogin>();
builder.Services.AddHostedService<Worker>();

builder.Build().Run();

AddWindowsService does nothing when you start the program yourself, so the same build runs in a console while you test it and as a service later. The CapSkip SDK reads no environment variables of its own, but the configuration object behind Host.CreateApplicationBuilder reads every environment variable, so CAPSKIP_HOST works from the environment. It also works as a key in appsettings.json, which is the easier place for a service, because a service’s environment is awkward to edit. When Windows starts a service, its working folder is System32. The host then uses the program’s own folder as its content root, so it reads the appsettings.json published next to the exe.

Step 2: the job that solves the CAPTCHA

The job itself is ordinary code. It fetches the login page, reads the sitekey and the hidden fields, asks CapSkip for a token and posts the form. Keep it in its own class, so the worker only decides when it runs and what a failure means.

// PortalLogin.cs
using System.Net;
using System.Text.RegularExpressions;
using CapSkip;

namespace PortalWorker;

public sealed class PortalLogin(CapSkipClient solver, ILogger<PortalLogin> logger)
{
    const string PageUrl = "https://example.com/login";

    public async Task RunAsync(CancellationToken ct)
    {
        // A new HttpClient per run keeps its own cookies, so one run's
        // session never leaks into the next.
        using var http = new HttpClient();
        var html = await http.GetStringAsync(PageUrl, ct);
        var sitekey = Regex.Match(html, "data-sitekey=\"([^\"]+)\"").Groups[1].Value;

        // The form's hidden fields, such as a CSRF token.
        var fields = new Dictionary<string, string>();
        foreach (Match input in Regex.Matches(html, "<input[^>]*type=\"hidden\"[^>]*>"))
        {
            var name = Regex.Match(input.Value, "name=\"([^\"]+)\"").Groups[1].Value;
            var value = Regex.Match(input.Value, "value=\"([^\"]*)\"").Groups[1].Value;
            fields[name] = WebUtility.HtmlDecode(value);
        }

        // Pass the token on, so stopping the service cancels the solve.
        var result = await solver.RecaptchaAsync(sitekey, PageUrl, cancellationToken: ct);

        fields["username"] = "YOUR_USERNAME";
        fields["password"] = "YOUR_PASSWORD";
        fields["g-recaptcha-response"] = result.Code;
        using var post = new HttpRequestMessage(HttpMethod.Post, PageUrl)
        {
            Content = new FormUrlEncodedContent(fields),
        };
        // HttpClient sends no Referer; strict servers refuse a post without one.
        post.Headers.Referrer = new Uri(PageUrl);
        using var response = await http.SendAsync(post, ct);
        if (response.IsSuccessStatusCode)
            logger.LogInformation("Login posted: {Status}", (int)response.StatusCode);
        else
            logger.LogWarning("Login rejected: {Status}", (int)response.StatusCode);
    }
}

Three details are easy to miss. A new HttpClient per run keeps cookies for that run only, so the session cookie and the CSRF token in the hidden fields still match when the form goes back. HttpClient sends no Referer header, and frameworks such as Django refuse an HTTPS form post without one, so the job sets it. And the stopping token goes to RecaptchaAsync, which is what lets the service stop in the middle of a solve rather than wait for it.

Solve right before you post. A reCAPTCHA token expires two minutes after it is issued, so a worker that solves early and posts later sends a dead token. The page parsing here is deliberately small. Invisible widgets, Enterprise keys and other variants are covered in the guide to solving reCAPTCHA v2 in C#.

Step 3: a loop that survives a failed solve

Most worker service CAPTCHA bugs live in this file. The template’s loop has no try block at all, which is fine for logging the time and fatal for anything that talks to another program.

// Worker.cs
using CapSkip;

namespace PortalWorker;

public sealed class Worker(PortalLogin job, ILogger<Worker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                var wait = TimeSpan.FromMinutes(15);
                try
                {
                    await job.RunAsync(stoppingToken);
                }
                catch (NetworkException ex) when (!stoppingToken.IsCancellationRequested)
                {
                    // CapSkip is not running yet, as after a reboot. Try again soon.
                    logger.LogWarning("CapSkip unreachable: {Message}", ex.Message);
                    wait = TimeSpan.FromSeconds(30);
                }
                catch (CapSkipError ex) when (!stoppingToken.IsCancellationRequested)
                {
                    logger.LogWarning("Solve failed: {Type}: {Message}", ex.GetType().Name, ex.Message);
                }
                catch (Exception ex) when ((ex is HttpRequestException or TaskCanceledException)
                                           && !stoppingToken.IsCancellationRequested)
                {
                    // The site failed, or HttpClient gave up on it after 100 s.
                    logger.LogWarning("The site did not answer: {Message}", ex.Message);
                }

                await Task.Delay(wait, stoppingToken);
            }
        }
        catch (Exception) when (stoppingToken.IsCancellationRequested)
        {
            // Stopping. A cancelled poll ends in TaskCanceledException and a
            // cancelled submit in NetworkException; neither is a failure.
        }
        catch (Exception ex)
        {
            logger.LogCritical(ex, "Worker failed");
            // Exit non-zero, so the service's recovery options restart it.
            // A host that stops on its own exits with code 0.
            Environment.Exit(1);
        }
    }
}

Each catch answers one question. A NetworkException means CapSkip did not answer, usually because it is not running yet, so the worker tries again in 30 seconds instead of 15 minutes. Any other CapSkipError is a failed solve, such as an ApiException with an error code from CapSkip, so the worker logs it and waits for the next run. An HttpRequestException or a TaskCanceledException that arrives while nothing is stopping comes from the site. A TaskCanceledException here means HttpClient gave up after its default 100 seconds. Every failure is logged at Warning, a rejected login included, for a reason the service section explains.

Filtering on the stopping token keeps a stop from looking like a failure. The outer catch then handles the stop itself, and the last catch handles everything nobody expected, such as a bug in the parsing. It logs the exception and ends the process with exit code 1, which is the one detail that lets Windows restart the service.

Why one exception stops the whole host

The host starts ExecuteAsync and watches the task it returns. Since .NET 6, if that task fails, the host applies HostOptions.BackgroundServiceExceptionBehavior, whose default is StopHost. Take the template’s loop and close CapSkip. A run on .NET 10 then logs BackgroundService failed with the NetworkException under it, followed by a critical line saying the host is stopping because a BackgroundService threw. Then it shuts down. One refused connection, and the worker is gone.

The exit code is what makes this nasty for a service. The host stops cleanly, so the process exits with code 0, which we measured on .NET 8 and .NET 10 alike. Windows applies a service’s recovery options only when it fails, and a clean exit is not a failure, so the service simply stays stopped. Microsoft’s own guide to running a BackgroundService as a Windows Service recommends calling Environment.Exit with a non-zero code for that reason, and the worker above does it in its last catch. The other setting, Ignore, is worse: the host keeps running with a dead worker inside it, a service that looks healthy and does nothing.

Stopping cleanly in the middle of a solve

When Windows stops the service, or you press Ctrl+C in the console, the host cancels the stopping token and waits for ExecuteAsync to finish. It waits up to HostOptions.ShutdownTimeout, 30 seconds by default, then stops anyway. A reCAPTCHA solve can take far longer than that, since the SDK polls for up to 300 seconds. Without the token, a stop requested 5 seconds into a 60 second solve took 30 seconds in our test: the host waited out ShutdownTimeout in full and then abandoned the solve. With the token passed through, the same stop took about 20 milliseconds.

Cancellation reaches you in two shapes, depending on where it lands. During the SDK’s polling it ends in a TaskCanceledException from the delay between polls. If it lands while the SDK is submitting the task, its first request to CapSkip, the SDK wraps it in a NetworkException, which is a CapSkipError and not an OperationCanceledException. Without a filter, that second case looks like a crash. We stopped a worker mid-submit and the host logged BackgroundService failed with The operation was canceled under it, on an ordinary shutdown. That is why every catch above checks the stopping token first.

Step 4: run it as a Windows Service

Publish the worker to a folder and register the exe with the service manager. Every command below except dotnet publish needs an elevated PowerShell.

# In an elevated PowerShell. Paths are examples.
dotnet publish -c Release -r win-x64 -o C:\Services\PortalWorker
sc.exe create PortalWorker binpath= "C:\Services\PortalWorker\PortalWorker.exe" start= delayed-auto
sc.exe failure PortalWorker reset= 86400 actions= restart/60000/restart/60000/restart/60000
sc.exe start PortalWorker

The failure line is what pairs with Environment.Exit(1). It tells Windows to restart the service a minute after a failure. Windows repeats the last action for every later failure, so the service keeps coming back, and the failure count resets after a day without failures. Delayed automatic start begins the service shortly after the other automatic services instead of at the very start of boot. CapSkip itself is a desktop application, so after a reboot it only runs once someone signs in to Windows, and the service may start well before that. The worker handles that case on its own: it logs CapSkip unreachable every 30 seconds until the solver answers. Each retry also loads the login page again, about 120 times an hour while Windows waits at the sign-in screen, so lengthen the 30 seconds if the site would notice. A service and a desktop app run in different Windows sessions, but 127.0.0.1 connects across sessions, so Local mode works between them.

As a service the host sends its logs to the Windows Event Log, in the Application log, and by default only entries at Warning level or above get there. That is why the worker logs its failures as warnings: an Information line would never show up in Event Viewer.

Several accounts in one run

To log in several accounts on each tick, loop inside the job with Parallel.ForEachAsync rather than starting more workers. Set MaxDegreeOfParallelism to 10, because CapSkip solves 10 reCAPTCHAs at a time by default (Max. Threads in its reCAPTCHA settings) and queues the rest. Pass the stopping token in the CancellationToken of ParallelOptions, and hand the token each iteration receives on to RecaptchaAsync, so a stop cancels every solve at once. Catch each account’s failures inside the loop body, with the same filters as the worker. An exception that escapes one account cancels the solves of all the others in that run. The CapSkipClient singleton is safe to share across those calls. Give each account its own HttpClient, as the job above does, so no two sessions share a cookie.

Running the solver on another machine

127.0.0.1 is right while the worker and CapSkip share a Windows PC. The worker may run elsewhere, on another Windows server, a VM or a container host. Then switch CapSkip to Server mode so it listens on your network address or public IP, and set CAPSKIP_HOST to that address in appsettings.json or the environment. Use a static public IP when the route crosses the internet and turn on API key validation. 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. The solver is still your own Windows machine, and solves are still unmetered.

Common worker service CAPTCHA errors

What you seeCauseFix
NU1605, a detected package downgrade, when adding the Windows Services packageThe template pins an older Microsoft.Extensions.Hosting than the Windows Services package needsAdd Microsoft.Extensions.Hosting first, then the Windows Services package
BackgroundService failed, then a critical line about StopHost, and the worker is goneAn exception escaped ExecuteAsync; the default StopHost behaviour stopped the hostCatch CapSkipError and the site’s exceptions inside the loop
The service stopped after an error and Windows never restarted itA host stopped by StopHost exits with code 0, which is not a failure to WindowsEnd unexpected failures with Environment.Exit(1) and set recovery with sc.exe failure
BackgroundService failed with The operation was canceled, on a normal stopThe stop landed while the task was being submitted to CapSkip, and the SDK wraps that in NetworkExceptionFilter every catch on the stopping token
Stopping the service takes 30 secondsThe solve ignores cancellation, so the host waits out ShutdownTimeoutPass the stopping token to RecaptchaAsync and to every HTTP call
CapSkip unreachable in the log after every rebootThe service started before anyone signed in and opened CapSkipNo fix needed: the worker retries every 30 seconds until CapSkip answers
NetworkException that the target machine actively refused the connectionCapSkip is not running, or the host or port is wrongStart CapSkip and check CAPSKIP_HOST and CAPSKIP_PORT against its connection settings
The site rejects the token, or answers with its login page againThe token expired before the post, or the post left out the cookie, a hidden field or the RefererSolve right before posting, from the HttpClient that fetched the page
Nothing from the worker in Event ViewerThe Event Log only takes Warning and above by defaultLog failures at Warning, or set an EventLog LogLevel of Information in appsettings.json
An ApiException with ERROR_KEY_DOES_NOT_EXIST or ERROR_WRONG_USER_KEYAPI key validation is on in CapSkip, and the worker sent the default key or noneSet CAPSKIP_API_KEY to a key configured in CapSkip

FAQ

Should I set BackgroundServiceExceptionBehavior to Ignore instead?

No. With Ignore the host logs the exception once as an error and carries on, but ExecuteAsync is never called again, so the process keeps running with nothing inside it. Windows reports the service as running, and no logins happen until someone notices. Catch the expected failures in the loop and let the rest end the process with a non-zero exit code.

Does the same apply to a hosted service in ASP.NET Core?

Yes. An ASP.NET Core app runs on the same Generic Host. So a BackgroundService in a web app has the same StopHost default and the same 30 second shutdown timeout, and an unhandled CapSkip exception takes the whole website down with it. The loop above works there unchanged, but note that its Environment.Exit(1) ends the whole web app too.

When is Hangfire a better fit than a worker?

When the solves are jobs that other code queues, rather than one task on a timer. Hangfire stores each job, retries it on its own schedule and shows the history in a dashboard, which a plain worker leaves to you. Its retry filter needs its own care with CapSkip’s exceptions, as explained in the guide to solving CAPTCHAs in Hangfire jobs.

Can the worker 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 set CAPSKIP_HOST to that address. The worker then calls it over the same HTTP API from any machine that can reach the port, with a Windows Firewall block rule for the addresses that should not. The solver stays on hardware you own, so nothing changes about how solves are counted.

The short version

A worker service CAPTCHA job needs three things the template does not give you. Catch CapSkipError and the site’s exceptions inside the loop, because one escaped exception stops the host and exits with code 0. Pass the stopping token to RecaptchaAsync and filter every catch on it, so a stop takes a moment rather than 30 seconds and never logs as a crash. And end truly unexpected failures with Environment.Exit(1), so the service’s recovery options can restart it. Retry every 30 seconds while CapSkip is not up, and switch to Server mode when the worker leaves the solver’s machine.

A worker that runs every 15 minutes for months makes thousands of solves. That is where a captcha bypass on your own Windows machine pays off: the count of solves never shows up on an invoice.