How to Solve CAPTCHAs in a Hangfire Background Job (.NET)

hangfire captcha - How to Solve CAPTCHAs in a Hangfire Background Job (.NET)

To solve a CAPTCHA in a Hangfire background job, call the CapSkip .NET client inside the job method, submit the token in that same job, and pass Hangfire’s CancellationToken to the solve. That part takes ten lines. The work is in two Hangfire defaults that suit ordinary jobs and not a Hangfire captcha job: ten automatic retries on any exception, spread over about four and a half hours, and a pool of up to twenty workers that an async solve still occupies from start to finish. This guide covers the job, a retry rule that knows which failures are worth repeating, a queue sized to CapSkip, shutdowns, and hosting Hangfire on the same Windows machine as the solver.

What you need

  • CapSkip running on a Windows machine. Hangfire can run on that same machine or call it from another one.
  • Hangfire 1.8 with any storage. The samples use SQL Server storage, the usual choice on Windows, and the retry filter below needs 1.8.0 or later.
  • The CapSkip NuGet package. Every solve method takes an optional CancellationToken, which is what lets a shutdown stop a solve cleanly.
  • .NET 8 or later. The samples use primary constructors, which arrived with C# 12.
  • An address for the solver. Local mode answers on 127.0.0.1 for that device only; Server mode listens on your network address or public IP so Hangfire on another box can call it over the API. Both are under connection settings.
# dotnet add package CapSkip
dotnet add package CapSkip
dotnet add package Hangfire.NetCore
dotnet add package Hangfire.SqlServer
dotnet add package Microsoft.Data.SqlClient

Hangfire.NetCore brings Hangfire.Core and the AddHangfire and AddHangfireServer registration methods. A web app that also wants the dashboard adds Hangfire.AspNetCore instead. Hangfire.SqlServer 1.8 ships without a SQL client of its own, which is why Microsoft.Data.SqlClient is on the list.

Step 1: solve and submit in one job

Put the solve and the form post in a single job method; every Hangfire captcha job in this guide is built on that rule. A reCAPTCHA token is good for about two minutes, so the submit cannot wait in a queue behind other work, which is exactly what a continuation created with ContinueJobWith would do. The same goes for a retry: every run must solve fresh, never resume from a token or a captcha id saved by an earlier attempt.

// dotnet add package CapSkip
using CapSkip;

public class SignupJob(CapSkipClient solver, HttpClient http)
{
    public async Task RunAsync(string pageUrl, string sitekey, CancellationToken ct)
    {
        // Solve and post together: the token lasts about 2 minutes.
        var result = await solver.RecaptchaAsync(sitekey, pageUrl, cancellationToken: ct);

        var form = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["g-recaptcha-response"] = result.Code,
        });
        // No ct here: once the submit starts, let it finish.
        var response = await http.PostAsync(pageUrl, form);
        response.EnsureSuccessStatusCode();   // a rejected post fails the job
    }
}

Enqueue it with plain values. Hangfire serializes the arguments into storage, so pass strings, never the client, and pass CancellationToken.None as a placeholder. Hangfire swaps in the real token just before the job runs.

BackgroundJob.Enqueue<SignupJob>(job =>
    job.RunAsync("https://example.com/signup", "YOUR_SITEKEY", CancellationToken.None));

Hangfire builds SignupJob from your service container, so register the CapSkip client as a singleton and give the job a typed HttpClient, as the full example does. The client holds nothing but its settings, so one instance is safe to share across every worker. The form fields here are illustrative; post whatever the target form really sends.

Step 2: replace the default retry rule

Hangfire applies an automatic retry filter to every job. By default it retries any exception ten times, and the delay before retry n is (n minus 1) to the fourth power in seconds, plus 15, plus a random amount, which adds up to roughly four and a half hours between the first failure and the last. That suits a flaky email server. It does not suit a CAPTCHA job, where some failures are worth one more try and others will fail identically every time.

What the SDK throwsUsual causeWorth retrying?
CapSkip.TimeoutExceptionThe solve outlasted recaptchaTimeout, 300 seconds by default, or CapSkip was unreachable or restarted while the SDK polledYes
NetworkExceptionCapSkip was not reachable when the job submitted the taskYes
ApiException with ERROR_CAPTCHA_UNSOLVABLEThat attempt failed, or ran out of time inside CapSkip; the next one may notYes
ApiException with any other codeA malformed sitekey or page URL, or an API key CapSkip refusesNo, it fails the same way each time
ValidationExceptionYour code sent an option the method does not takeNo, it is a bug

One limit of that table: CapSkip can only check that a sitekey is well formed. A well-formed key that Google rejects still reads as ERROR_CAPTCHA_UNSOLVABLE, so it uses its retries before the job fails.

Hangfire 1.8 added OnlyOn to the retry attribute, which limits retries to the exception types you list. ApiException covers both a transient and a permanent row, so the job converts the permanent kind into an exception type that is not on the list:

[AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 120, 600 },
    OnlyOn = new[] { typeof(CapSkip.TimeoutException),
                     typeof(NetworkException), typeof(ApiException) })]
public async Task RunAsync(string pageUrl, string sitekey, CancellationToken ct)
{
    SolveResult result;
    try
    {
        result = await solver.RecaptchaAsync(sitekey, pageUrl, cancellationToken: ct);
    }
    catch (ApiException ex) when (!ex.Message.Contains("ERROR_CAPTCHA_UNSOLVABLE"))
    {
        // Not on the OnlyOn list, so Hangfire fails the job at once.
        throw new InvalidOperationException($"CapSkip refused the task: {ex.Message}", ex);
    }
    // ...post the form as in Step 1.
}

Attempts counts retries, so this is one run plus up to three more. A job that runs out of attempts, or throws something outside the list, lands in the Failed state and stays there for you to inspect. Write CapSkip.TimeoutException in full: with the implicit usings of a .NET 6 or later project, System.TimeoutException is in scope too, and the short name will not compile. The ApiException message is the raw reply from CapSkip, which is why matching on the error code works, and the full list of codes is in the API reference.

Step 3: give solves their own queue and worker count

A Hangfire server runs Environment.ProcessorCount times 5 workers, capped at 20. Making the job async does not free a worker while the solve waits: Hangfire runs each job on its worker thread and waits there until the task completes. So twenty solves in flight means twenty workers busy for as long as the solves take, and every other job in the application, password-reset emails included, waits behind them.

CapSkip has a limit of its own. reCAPTCHA Max. Threads defaults to 10 in the app settings, and tasks above that wait inside CapSkip for a free thread. One that waits longer than the reCAPTCHA Wait Timeout, 250 seconds by default, is failed as ERROR_CAPTCHA_UNSOLVABLE. So give every Hangfire captcha job a queue of its own, with exactly as many workers as CapSkip has threads:

// On the job method, next to [AutomaticRetry]:
[Queue("captcha")]

// In Program.cs: one server for solves, sized to CapSkip...
builder.Services.AddHangfireServer(o =>
{
    o.Queues = new[] { "captcha" };
    o.WorkerCount = 10;   // reCAPTCHA Max. Threads in CapSkip
});

// ...and the usual server for everything else.
builder.Services.AddHangfireServer(o => o.Queues = new[] { "default" });

Now a burst of five hundred solves queues in Hangfire storage, where you can see it, and this server never has more than ten with CapSkip at once. WorkerCount counts per server, so if you run the captcha server in more than one process, split Max. Threads between them. The attribute is applied again whenever the job is enqueued, so retries return to the captcha queue too. Queue names take lowercase letters, digits, underscores and dashes only. If you raise Max. Threads in CapSkip, raise WorkerCount to match.

Step 4: shutdowns, and where Hangfire runs

The CancellationToken Hangfire passes in fires in two cases: the server is shutting down, for a service stop, a deploy or an IIS recycle, or the job was deleted or changed state in the dashboard, which Hangfire checks every five seconds by default. Handing it to RecaptchaAsync stops the polling straight away. On shutdown, Hangfire then puts the job back on its queue and runs it again after the restart, with a fresh solve. On deletion it drops the job. CapSkip finishes the abandoned task by itself, and nobody collects the result.

There is one narrow catch. Hangfire only re-queues when the job ends with an OperationCanceledException, and the SDK raises exactly that while it polls. But if the token fires while the SDK is still submitting the task, its first request to CapSkip, the SDK wraps the cancellation in NetworkException. Hangfire then treats the run as a failed attempt: with the rule from Step 2, it schedules a retry after the next delay on the list and uses up one of the three. One catch clause, placed before the ApiException filter, turns it back into a cancellation:

catch (CapSkipError) when (ct.IsCancellationRequested)
{
    // A cancelled submit arrives as NetworkException;
    // rethrow as cancellation so Hangfire re-queues the job.
    throw new OperationCanceledException(ct);
}

If the process dies outright with no shutdown at all, SQL Server storage hands the job to another worker once its invisibility timeout lapses, five minutes by default in Hangfire 1.8. Either way Hangfire runs a job at least once, not exactly once, which is why the sample lets a form post that has already started finish instead of cancelling it.

Where you host the Hangfire server matters as much. Inside an IIS site, the application pool stops after 20 idle minutes by default and recycles on a schedule, and a stopped pool means no Hangfire server: recurring solves do not fire until the next web request wakes the site. Either set the pool’s Start Mode to AlwaysRunning, its Idle Time-out to 0 and Preload Enabled on the site, which needs IIS’s Application Initialization feature installed, or run the server in a Windows Service, which the full example does. On the same Windows machine as CapSkip, Local mode and 127.0.0.1 are all it needs.

Plan for one difference between the two. The service starts at boot, but CapSkip is a desktop app that starts when you sign in to Windows. After an unattended reboot, every submit fails with NetworkException until someone signs in, and each job spends its retries on the gap. Keep that machine signed in, or check it after every restart.

When Hangfire runs elsewhere, such as a second server, a container host or an Azure App Service, switch CapSkip to Server mode so it listens on your network address or public IP, and point the client there. Use a static public IP if the route crosses the internet, with a firewall rule for the addresses you expect. It is still your own hardware and still unmetered. The client does not read environment variables by itself, so read CAPSKIP_HOST in your startup code and pass it to the constructor.

Full working example

A worker service that runs as a Windows Service, with the job from the steps above and a recurring schedule. These are all the commands it needs:

# dotnet new worker -n CaptchaWorker
dotnet new worker -n CaptchaWorker
cd CaptchaWorker
dotnet add package CapSkip
dotnet add package Hangfire.NetCore
dotnet add package Hangfire.SqlServer
dotnet add package Microsoft.Data.SqlClient
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Hosting.WindowsServices
dotnet add package Microsoft.Extensions.Http
dotnet add package Newtonsoft.Json

Three of those lines need a reason. Microsoft.Data.SqlClient encrypts connections by default, so a local SQL Server without a trusted certificate needs TrustServerCertificate=true in the connection string. The Microsoft.Extensions.Hosting line lifts the template’s own Hosting reference to the version the Windows Services package expects; without it, restore fails with a package downgrade error. Newtonsoft.Json moves Hangfire’s JSON dependency off an old version that NuGet flags as vulnerable.

// dotnet add package CapSkip
using CapSkip;
using Hangfire;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService();

builder.Services.AddSingleton(new CapSkipClient(
    apiKey: Environment.GetEnvironmentVariable("CAPSKIP_API_KEY") ?? "capskip",
    host: Environment.GetEnvironmentVariable("CAPSKIP_HOST") ?? "127.0.0.1",
    port: 8080));
builder.Services.AddHttpClient<SignupJob>();

builder.Services.AddHangfire(cfg => cfg
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")));

builder.Services.AddHangfireServer(o =>
{
    o.Queues = new[] { "captcha" };
    o.WorkerCount = 10;   // reCAPTCHA Max. Threads in CapSkip
});

var host = builder.Build();
host.Services.GetRequiredService<IRecurringJobManager>().AddOrUpdate<SignupJob>(
    "nightly-signup",
    job => job.RunAsync("https://example.com/signup", "YOUR_SITEKEY", CancellationToken.None),
    Cron.Daily());
host.Run();

public class SignupJob(CapSkipClient solver, HttpClient http)
{
    [Queue("captcha")]
    [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 120, 600 },
        OnlyOn = new[] { typeof(CapSkip.TimeoutException),
                         typeof(NetworkException), typeof(ApiException) })]
    public async Task RunAsync(string pageUrl, string sitekey, CancellationToken ct)
    {
        SolveResult result;
        try
        {
            result = await solver.RecaptchaAsync(sitekey, pageUrl, cancellationToken: ct);
        }
        catch (CapSkipError) when (ct.IsCancellationRequested)
        {
            throw new OperationCanceledException(ct);
        }
        catch (ApiException ex) when (!ex.Message.Contains("ERROR_CAPTCHA_UNSOLVABLE"))
        {
            throw new InvalidOperationException($"CapSkip refused the task: {ex.Message}", ex);
        }

        var form = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["g-recaptcha-response"] = result.Code,
        });
        var response = await http.PostAsync(pageUrl, form);
        response.EnsureSuccessStatusCode();
    }
}

This service only processes the captcha queue. Your web app enqueues into the same SQL Server storage and keeps its own server for the default queue, or you add a second AddHangfireServer call here as in Step 3. Once a web app enqueues the job too, move SignupJob out of Program.cs into a class library that both projects reference, since the web app needs the type to enqueue it. The project needs a connection string named Hangfire in appsettings.json. Install it with sc.exe create, or New-Service in PowerShell, pointing at the published executable.

Swap RecaptchaAsync for TurnstileAsync, FriendlyCaptchaAsync or any other solve method and the job shape stays the same. Two things change with the type: size WorkerCount to that type’s own Max. Threads setting in CapSkip, and post the token in the field that type uses.

Common errors and what they mean

What you seeCauseFix
Jobs sit in Enqueued and never startThe method has [Queue("captcha")] but no server listens on that queueAdd a server whose Queues includes captcha
One bad job retries for hoursThe default retry filter, ten attempts on any exceptionUse AutomaticRetry with OnlyOn as in Step 2
A retry reading The operation was canceled, right after a deployThe token fired while the task was being submitted, and the SDK wrapped itAdd the cancellation catch from Step 4
NetworkException on every jobCapSkip is not running, for example after a reboot with nobody signed in, or Hangfire is on another machine and CapSkip is in Local modeStart CapSkip; if Hangfire is on another machine, switch CapSkip to Server mode and set CAPSKIP_HOST
ERROR_CAPTCHA_UNSOLVABLE, or CapSkip.TimeoutException, under loadMore solves in flight than CapSkip has threads, so tasks wait past the Wait TimeoutMatch WorkerCount on the captcha server to Max. Threads
The job succeeds but the site rejects the postThe token expired before the submit, or the form needs other fieldsSolve and submit in one job, and mirror the real form in DevTools
Recurring solves skip nightsThe IIS application pool was idle or recyclingSet AlwaysRunning, or host the server in a Windows Service
The build fails on an ambiguous TimeoutExceptionCapSkip and System both define that nameWrite CapSkip.TimeoutException in full

FAQ

Does an async job free its Hangfire worker while the CAPTCHA solves?

No. Hangfire supports async job methods, but it waits for the returned task on the worker thread that started it, so the worker stays busy for the whole solve. That is why, for a Hangfire captcha job, the worker count on its queue is the number that limits how many solves run at once, and why it should match the threads CapSkip has.

Can I solve in one job and submit in a continuation?

You can, but you should not. A continuation is enqueued like any other job, so it waits behind whatever is already queued, and a reCAPTCHA token stays valid for about two minutes. A retry of the second job would also re-post the same expired token. Keeping both in one job means every attempt solves fresh and submits straight away. More on how long tokens last is in the reCAPTCHA token expiration guide.

Can Hangfire on Azure or a Linux container use a solver on my Windows PC?

Yes. The Hangfire side can run anywhere .NET runs; only CapSkip needs Windows. Put CapSkip in Server mode under connection settings, set CAPSKIP_HOST to its address wherever Hangfire runs, and pass it to the client. Allow the connection through Windows Firewall, and use a static public IP when the route crosses the internet. Hosted platforms add limits of their own, covered for one of them in the Azure Functions guide.

How does this compare with Celery in Python?

The rule that matters most is the same in both: solve fresh on every attempt and submit in the same unit of work. The traps differ. Celery’s come from its time limits and acknowledgement settings, while Hangfire’s come from its retry defaults, workers that async code does not release, and the way cancellation is reported. The Python side is in the Celery CAPTCHA guide.

The short version

In a Hangfire captcha job, solve and submit in one method and pass Hangfire’s CancellationToken to the solve. Replace the default retry rule with AutomaticRetry and OnlyOn, and turn permanent ApiExceptions into an exception that is not on the list. Give solves a queue whose worker count matches CapSkip’s threads, rethrow a cancelled submit as OperationCanceledException so shutdowns re-queue, and host the server where it cannot go idle: a Windows Service beside CapSkip, or anywhere else with Server mode.

One last point about retries. Because the solver runs on your own machine, a retry costs a few seconds of a thread rather than another billed solve, so a captcha bypass that fails once is cheap to try again. What the retry rule really guards against is jobs that will never succeed, and those are the ones to stop early.