How to Solve CAPTCHAs in an Azure Function (C# Isolated)

An Azure Functions captcha solve breaks for a reason your code never shows you. An HTTP-triggered function has 230 seconds to respond to a request no matter what timeout you configure, because that limit comes from the load balancer in front of the platform. The reCAPTCHA polling timeout in the CapSkip client defaults to 300 seconds. So a slow solve gets cut off by Azure, not by your function, and the log shows a request that simply ended. The fix is to stop solving on the HTTP request at all. The other thing to get right is loopback, because a function app runs on Azure’s machines and not on yours.
What you need
- A .NET isolated worker function app. Support for the in-process model ends on 10 November 2026, so the isolated worker is the model to build on.
- CapSkip running on a Windows machine, in Server mode, at an address the function app can reach.
- A storage account, since the pattern below moves the solve onto a queue-triggered function.
- The sitekey and page URL arriving on the queue message rather than hardcoded, so one function serves every form.
# dotnet add package CapSkip dotnet add package CapSkip dotnet add package Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues
Step 1: loopback in a function app is the function app
Worth settling before anything else, because it decides whether the rest works. Your function runs on an instance Azure provisions, so 127.0.0.1 inside it is that instance. Nothing is listening on port 8080 there, and the failure arrives as a NetworkException on the first solve after deploy while the same code worked perfectly under the local tooling.
There are two connection modes. Local binds to 127.0.0.1 and answers that device only, which is right when your automation and the solver share a machine. Server binds to your network address or public IP, so another box, a VPS or a hosted platform can reach the same Windows machine over the API. Server mode changes only which address the solver listens on. It is still your hardware and it is still unmetered. Both modes live under connection settings.
| How you are running the function | Which connection mode |
|---|---|
| Local tooling, on the CapSkip machine | Local mode. 127.0.0.1 is genuinely correct |
| Deployed, with the solver on a network Azure can route to | Server mode with that private address |
| Deployed, with the solver reached over the internet | Server mode with a static public IP and a firewall rule |
Two Azure features are worth knowing about when the solver sits on a network Azure can route to. Virtual network integration for outbound traffic is available on the Flex Consumption, Premium and Dedicated plans, and is not available on the legacy Consumption plan at all. Hybrid Connections, which are built for reaching a service that stays on your own network, are available on the Premium and Dedicated plans for apps running on Windows. Either way the solver stays on your hardware; only the route changes.
Put the address in an application setting rather than the source, because the local tooling and the deployed app want different values. The client does not read any environment variable by itself, so read CAPSKIP_HOST in your code and pass it in, as the function below does.
// dotnet add package CapSkip
using CapSkip;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
// One client for the app. The host is an application
// setting, so local and deployed can differ.
builder.Services.AddSingleton(new CapSkipClient(
host: Environment.GetEnvironmentVariable("CAPSKIP_HOST") ?? "127.0.0.1",
port: 8080));
builder.Build().Run();Step 2: the 230 second wall, and why it is not your timeout
This is the part that wastes an afternoon, because every number you can see in the portal is larger than the one that actually kills the request.
Microsoft documents it plainly: regardless of the function app timeout setting, 230 seconds is the maximum amount of time an HTTP-triggered function can take to respond to a request, and the limit exists because of the default idle timeout on the Azure Load Balancer. You cannot raise it from host.json, from an application setting, or by changing plan.
Now put the client’s own numbers next to it. The reCAPTCHA, Turnstile and GeeTest polling timeout defaults to 300 seconds, and the image timeout to 120 seconds. So an image solve fits inside the wall comfortably, and a reCAPTCHA solve is allowed to run 70 seconds past it. Most solves finish long before either number, which is exactly why this ships to production and then fails on the slow tail.
| Limit | Value | Can you change it? |
|---|---|---|
| HTTP response, any plan | 230 seconds | No |
| Client reCAPTCHA polling timeout | 300 seconds | Yes, on the constructor |
| Client image polling timeout | 120 seconds | Yes, on the constructor |
Lowering the reCAPTCHA polling timeout under 230 seconds is worth doing anyway, because a client that gives up first produces a CapSkip.TimeoutException you can log rather than a request that vanishes. It is not the real fix, though. The real fix is the one the Azure docs give: use the Durable Functions async pattern, or defer the actual work and return an immediate response. In practice that means the HTTP trigger accepts the job, writes a message, and returns straight away.
[Function(nameof(EnqueueSolve))]
[QueueOutput("captcha-jobs")]
public SolveRequest EnqueueSolve(
[HttpTrigger(AuthorizationLevel.Function, "post")] SolveRequest req)
{
// Returns in milliseconds. The solve happens on the
// queue-triggered function, off the HTTP request.
return req;
}Step 3: the function app timeout is a separate limit
Once the solve is off the HTTP request, the timeout that matters is the one in host.json, and it varies by plan. The defaults are generous everywhere except the legacy Consumption plan, which is the one plan where a slow reCAPTCHA solve can genuinely run out of room.
| Hosting plan | Default timeout | Maximum timeout |
|---|---|---|
| Flex Consumption plan | 30 minutes | No enforced maximum |
| Premium plan | 30 minutes | No enforced maximum |
| Dedicated plan | 30 minutes | No enforced maximum, with Always On |
| Consumption plan, legacy | 5 minutes | 10 minutes |
Five minutes is exactly 300 seconds, so the legacy plan does not cover a reCAPTCHA timeout at its default: the function dies at the same moment the client would have given up. Raise it if you are still on that plan, and keep the client’s own timeout underneath whatever you set.
{
"version": "2.0",
"functionTimeout": "00:10:00"
}Step 4: how many times a queue message gets re-solved
Moving the solve onto a queue buys you room, and it brings its own re-run behaviour that wastes real time if you leave it alone.
When a queue-triggered function fails, Azure Functions runs the function up to five times for that message, the first try included. If all five fail, the runtime writes the message to a queue named after the original with a poison suffix. That is five solves for one CAPTCHA if the failure is something that will never succeed, like a sitekey that does not belong to the page.
It gets tighter than it looks. The visibility timeout in host.json defaults to zero, which means a failed message reappears immediately, so those five attempts can happen back to back in seconds. Set it to something that gives a transient problem time to clear, and read the dequeue count in the function so a message on its final attempt can be handled differently.
Concurrency is the other half. By default the trigger takes a batch of 16 messages, then fetches another 16 as soon as the number still in flight drops to 8. Those 8 are still running while the new batch starts, so a single instance can have 24 solves going at once for one function. When the app scales out, that number multiplies by the number of instances. On a metered service you would cap that to protect a balance. Here it is a capacity question about one Windows machine, but 24 concurrent solves per instance is still a decision worth making on purpose rather than inheriting.
The sample below deliberately sits under both defaults: three attempts rather than five, and a batch of eight rather than sixteen.
{
"version": "2.0",
"extensions": {
"queues": {
"batchSize": 8,
"newBatchThreshold": 4,
"visibilityTimeout": "00:00:30",
"maxDequeueCount": 3
}
}
}Full working example
The queue-triggered half, with the solve and the submission in the same invocation.
// dotnet add package CapSkip
using CapSkip;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
public class SolveCaptcha(CapSkipClient solver, ILogger<SolveCaptcha> log)
{
[Function(nameof(SolveCaptcha))]
public async Task Run([QueueTrigger("captcha-jobs")] SolveRequest job)
{
try
{
// Solve and submit together. The token is short lived.
var result = await solver.RecaptchaAsync(job.Sitekey, job.PageUrl);
await SubmitFormAsync(job.PageUrl, result.Code);
}
catch (CapSkip.ValidationException ex)
{
// A bad sitekey fails identically on all five tries.
log.LogError("Not retryable: {Message}", ex.Message);
}
}
}That call is reCAPTCHA v2. The other types are the same shape: pass an options dictionary with invisible or enterprise set to 1, or version set to v3 with an action, or call TurnstileAsync or GeetestAsync instead. The full surface is on the C# CAPTCHA solver page.
Challenge-page Turnstile is the one exception worth knowing about, because it needs two extra values out of the page, plus the user agent the solver used. That one has a guide of its own.
Swallowing the parameter error rather than rethrowing it is deliberate. A thrown exception is what starts the five-attempt cycle, and there is nothing a retry can do about a sitekey that does not match the page.
Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
| The HTTP request ends at about four minutes with no error | The 230 second load balancer limit, not your timeout | Return immediately and solve on a queue trigger |
| Works with the local tooling, NetworkException once deployed | Loopback in a function app is the Azure instance | Server mode, and set CAPSKIP_HOST in application settings |
| Five identical failures, then a message in the poison queue | A non-retryable error was thrown out of the function | Catch CapSkip.ValidationException and log it instead |
| Five attempts burned in under a minute | The queue visibility timeout defaults to zero | Set a visibility timeout so retries are spaced out |
| The build fails on an ambiguous TimeoutException | CapSkip and System both define that short name | Qualify it, or catch the base CapSkipError |
| ERROR_WRONG_USER_KEY inside an ApiException | CAPSKIP_API_KEY is unset in the deployed app | Add it to application settings, then restart the app |
| CAPCHA_NOT_READY from a hand-rolled poll | The result was read before it was finished | Let the client poll. It backs off on its own |
That last response is spelled the way it looks, and the missing letter is not a typo on our side, because the API really does return it that way. It is explained in full in the CAPCHA_NOT_READY guide.
FAQ
Can a function app in Azure reach a solver on my own network?
Yes. Switch CapSkip to Server mode under connection settings so it listens on a network address instead of loopback, then point CAPSKIP_HOST at it in application settings. For a private route, virtual network integration is available on the Flex Consumption, Premium and Dedicated plans, and Hybrid Connections on Premium and Dedicated for apps running on Windows. If you go over the internet instead, use a static public IP with a firewall rule that allows only the addresses you expect. The solver itself never leaves your hardware in any of these.
Why does my HTTP-triggered solve die at about four minutes?
Because 230 seconds is the ceiling for an HTTP-triggered function to respond, and it comes from the load balancer rather than from Functions. No plan, host.json value or application setting raises it. If you want the answer on the same request, you need the solve to finish well inside that window, which means lowering the client’s reCAPTCHA polling timeout from its default of 300 and accepting that slow solves will fail. The better answer is to hand the work to a queue-triggered function and return straight away.
Do I need Durable Functions for this?
Only if the caller has to poll for the result. Durable Functions gives you the async HTTP pattern with a status endpoint built in, which is worth it when a browser or a partner system is waiting on the outcome. If the solve is one step of your own pipeline, a storage queue is simpler and gives you the same escape from the 230 second limit. Keep the solve and whatever consumes the token in the same invocation either way, because the token expires quickly and an orchestration boundary is a good place to lose that race.
Why will my catch block not compile?
Because the client defines a TimeoutException and a ValidationException whose short names also exist in System, and a function file almost always has both namespaces in scope. Write CapSkip.TimeoutException and CapSkip.ValidationException in full, or catch the base CapSkipError and branch inside. The other two, NetworkException and ApiException, have no collision and can be caught by their short names.
The short version
Do not solve on an HTTP trigger. The request is cut off at 230 seconds by the load balancer whatever functionTimeout says, and the client’s reCAPTCHA timeout is longer than that by default. Accept the job, write a queue message, return immediately, and solve in the queue-triggered function. Space the queue retries out and catch the parameter errors, or one bad sitekey burns five attempts on a problem no retry can fix. Switch CapSkip to Server mode and put its address in application settings, because loopback inside a function app is Azure’s instance and not yours.
- The raw endpoints behind the client are documented in the CapSkip API documentation.
- The checkbox challenge itself is explained on the reCAPTCHA v2 solver page.
One thing to weigh before you pick a batch size: captcha bypass with CapSkip runs on a machine you already own, so the ceiling on concurrent solves is what that machine can carry rather than what the month’s invoice allows.
