So lösen Sie Captchas in Netlify Functions (Node.js SDK)

Ein Captcha-Lösen in Netlify Functions kann nicht in der synchronen Funktion laufen, die die Anfrage beantwortet. Netlify stoppt diese Funktion nach 60 Sekunden und lässt Sie das Limit nicht erhöhen, während ein reCAPTCHA-Lösevorgang Minuten dauern kann. Lösen Sie stattdessen in einer Hintergrundfunktion, die 15 Minuten bekommt, lassen Sie sie das Formular selbst senden und halten Sie das Ergebnis in Netlify Blobs fest, damit eine zweite Funktion es melden kann. Richten Sie das CapSkip Node.js SDK auf Ihren Solver im Server-Modus, denn 127.0.0.1 ist in einer Netlify-Funktion der Rechner von Netlify, nicht Ihrer. Kümmern Sie sich dann um Wiederholungen: Eine Hintergrundfunktion, die eine Exception wirft, läuft erneut, und eine unachtsam gebaute sendet das Formular zweimal.
Was Sie brauchen
- CapSkip auf einem Windows-Rechner, den Sie kontrollieren, im Server-Modus, mit einer statischen öffentlichen IP und einem aus dem Internet erreichbaren Port des Solvers. Den Server-Modus finden Sie unter Verbindungseinstellungen, und Schritt 1 erklärt den Rest.
- Eine Netlify-Website mit Funktionen in netlify/functions, gebaut mit Node.js 22.12 oder neuer, wie es @netlify/blobs voraussetzt. Netlify führt Funktionen mit der Node.js-Version aus, die Ihr Build verwendet. Hintergrundfunktionen (Background Functions) gibt es in jedem Credit-basierten Tarif, auch im Free-Tarif; Legacy-Tarife unterscheiden sich, prüfen Sie also Ihren.
- Das Paket capskip in Version 1.3.0 oder neuer, dazu @netlify/blobs für die Job-Einträge und cheerio zum Auslesen der Seite. Innerhalb einer Funktion braucht Blobs keine Einrichtung: Netlify trägt die Website und den Token für Sie ein.
- Die URL der Seite mit dem Captcha. Die Beispiele lösen reCAPTCHA v2, und derselbe Aufbau funktioniert für jeden Typ, den CapSkip beherrscht.
# npm install capskip @netlify/blobs cheerio npm install capskip @netlify/blobs cheerio
Warum eine synchrone Funktion das nicht schafft
Netlify gibt jeder Funktionsart ein festes Ausführungslimit vor, nachzulesen in seiner Dokumentation zur Funktionskonfiguration. Keines der drei lässt sich ändern:
| Funktionstyp | Ausführungslimit | Ihre Aufgabe in diesem Aufbau |
|---|---|---|
| Synchron | 60 Sekunden | Das Ergebnis eines Jobs melden |
| Geplant | 30 Sekunden | Einen Job zeitgesteuert starten |
| Hintergrund | 15 Minuten | Lösen, dann das Formular senden |
Vergleichen Sie das nun mit einem Captcha-Lösen in Netlify Functions. Das SDK wartet bis zum recaptchaTimeout, standardmäßig 300 Sekunden, auf eine reCAPTCHA-Antwort, und CapSkip selbst lässt eine Aufgabe 250 Sekunden auf einen freien Thread warten und weitere 250 mit dem Lösen verbringen. Ein Lösevorgang, der an einem ruhigen Nachmittag 20 Sekunden dauert, braucht 90, wenn alle Threads belegt sind oder ein Proxy langsam ist. Erreicht eine synchrone Funktion 60 Sekunden, beendet Netlify sie. CapSkip weiß davon nichts, arbeitet den Job also bis zum Ende ab, und niemand holt die Antwort je ab.
context.waitUntil sieht nach einem Ausweg aus, denn es hält eine Funktion am Laufen, nachdem die Antwort schon verschickt ist. Das ist es aber nicht. Laut der Netlify-Dokumentation läuft die Funktion auch dann nur bis zu ihrem Ausführungslimit, asynchrone Arbeit eingeschlossen, ein an waitUntil übergebener Lösevorgang stirbt also genauso nach 60 Sekunden. Auch Streaming-Antworten unterliegen diesem Limit von 60 Sekunden.
Eine Hintergrundfunktion antwortet dem Aufrufer sofort mit 202 und läuft bis zu 15 Minuten weiter. Der Preis steckt in dieser 202: Niemand erhält den Rückgabewert der Funktion. Und ein reCAPTCHA-Token läuft etwa zwei Minuten nach seiner Ausstellung ab, er kann also nicht darauf warten, dass ein Aufrufer vorbeikommt und ihn abholt. Die Hintergrundfunktion muss den Token selbst verwenden, indem sie das Formular sendet, und einen Eintrag darüber hinterlassen, was passiert ist.
Schritt 1: Das SDK auf Ihren Solver im Server-Modus richten
In einer Netlify-Funktion ist 127.0.0.1 die eigene Sandbox der Funktion. Ein Client mit den Standardeinstellungen verbindet sich dort mit nichts und löst eine NetworkException mit ECONNREFUSED aus. Schalten Sie CapSkip in den Server-Modus, damit es auf Ihrer öffentlichen IP lauscht, leiten Sie den Port an den Windows-Rechner weiter, falls dieser hinter einem Router steht, und geben Sie den Port in der Windows Firewall frei.
Entscheiden Sie dann, wer sich verbinden darf. Standardmäßig wechseln die Adressen, von denen aus sich Netlify-Funktionen verbinden, wenn Netlify skaliert, eine Firewall-Regel kann sie also nicht auflisten. Private Connectivity von Netlify gibt Funktionen einen festen Satz von IPs, die Sie freigeben können, ist aber ein Add-on für Enterprise-Tarife. Ohne dieses Add-on übernimmt die Einstellung API Key Validation von CapSkip die Rolle des Schlosses: Schalten Sie sie ein, fügen Sie einen Schlüssel für diese Website hinzu und übergeben Sie ihn als apiKey. Jede SDK-Anfrage trägt den Schlüssel mit, über einfaches HTTP wie der Rest des Aufrufs, geben Sie der Funktion also einen eigenen Schlüssel, den Sie löschen können, ohne etwas anderes kaputt zu machen.
Speichern Sie Adresse und Schlüssel als Umgebungsvariablen in Netlify, unter Project configuration und dann Environment variables, mit einem Geltungsbereich (Scope), der Functions umfasst. Zwei Netlify-Regeln bringen hier viele ins Straucheln. Variablen, die in netlify.toml deklariert sind, erreichen Funktionen überhaupt nie. Und jedes Deployment behält die Werte, die beim Build gesetzt waren, ein neuer CAPSKIP_HOST bewirkt also nichts, bis Sie erneut deployen.
import { CapSkip } from "capskip";
// The SDK does not read these by itself, so pass them in.
if (!process.env.CAPSKIP_HOST) throw new Error("CAPSKIP_HOST is not set");
const solver = new CapSkip({
apiKey: process.env.CAPSKIP_API_KEY, // a key from API Key Validation
host: process.env.CAPSKIP_HOST, // your public IP, Server mode
port: Number(process.env.CAPSKIP_PORT ?? 8080),
});Behalten Sie diese Prüfung bei. Fehlt die Variable, ist host undefined, und das SDK fällt kommentarlos auf seinen Standardwert 127.0.0.1 zurück, womit Sie direkt wieder bei ECONNREFUSED landen. Im vollständigen Beispiel steht die Prüfung im try-Block des Lösevorgangs, sodass eine fehlende Variable im Job-Eintrag landet, statt die Funktion zu stoppen, bevor sie einen Eintrag schreiben kann.
Schritt 2: In einer Hintergrundfunktion lösen und senden
Um eine Hintergrundfunktion zu erstellen, genügt es, background in der config der Funktion auf true zu setzen. Diese hier prüft ein gemeinsames Secret, denn ihre URL ist öffentlich, und jede Anfrage an sie beschäftigt Ihren Solver. Dann ruft sie die Seite ab, liest den sitekey aus und löst:
// netlify/functions/solve-signup.mjs
import { getStore } from "@netlify/blobs";
import * as cheerio from "cheerio";
// solver from Step 1; PAGE_URL is the page with the CAPTCHA.
export default async (req) => {
if (req.headers.get("x-job-secret") !== process.env.JOB_SECRET) return;
const { jobId, email } = await req.json();
const jobs = getStore({ name: "captcha-jobs", consistency: "strong" });
const page = await fetch(PAGE_URL);
const $ = cheerio.load(await page.text());
const result = await solver.recaptcha($(".g-recaptcha").attr("data-sitekey"), PAGE_URL);
// Then claim the job and post the form, below.
};
export const config = { path: "/api/solve-signup", method: "POST", background: true };Die Antwort für reCAPTCHA steht in result.code. Verwenden Sie sie sofort: Bei einer Lebensdauer von zwei Minuten ist der Lauf, der löst, auch der Lauf, der sendet.
Nun zu den Wiederholungen. Endet eine Hintergrundfunktion mit einem Fehler, führt Netlify sie eine Minute später erneut aus, und wenn auch das scheitert, noch einmal zwei Minuten danach. Bevor das Formular gesendet ist, ist eine Wiederholung genau das, was Sie wollen: eine frische Seite und ein frischer Lösevorgang. Nachdem das Formular gesendet ist, ist eine Wiederholung eine zweite Registrierung. Deshalb reserviert die Funktion den Job in Blobs, bevor sie sendet:
// Only one run can create this key, so only one run posts.
const claim = await jobs.setJSON(`${jobId}-posted`, { at: Date.now() }, { onlyIfNew: true });
if (!claim.modified) return;
const form = $("form").has(".g-recaptcha");
const body = new URLSearchParams(form.serializeArray().map((f) => [f.name, f.value]));
body.set("email", email);
body.set("g-recaptcha-response", result.code);
const cookie = page.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
method: "POST",
headers: { cookie, referer: PAGE_URL },
body,
});
await jobs.setJSON(jobId, { state: "done", status: res.status });Mit onlyIfNew gelingt der Schreibvorgang nur, wenn der Schlüssel noch nicht existiert, und modified gibt an, ob dieser Lauf ihn angelegt hat. Zwei Läufe, die um denselben Job konkurrieren, können nicht beide true bekommen, also sendet nur einer von ihnen. Die Funktion prüft diesen Schlüssel außerdem vor dem Start, sodass ein erneuter Lauf eines fertigen Jobs zurückkehrt, ohne einen Lösevorgang zu verbrauchen.
Das Senden selbst überträgt die Cookies der Seite und die eigenen Felder des Formulars, einschließlich eines versteckten CSRF-Tokens, und genau das prüfen die meisten Registrierungsformulare neben dem Captcha. Außerdem sendet es die Seite als Referer, denn fetch sendet keinen, und manche Frameworks lehnen einen HTTPS-Formular-POST ohne ihn ab. Sobald die Reservierung steht, halten Sie jeden Fehler in Blobs fest, statt eine Exception zu werfen. Eine Wiederholung würde an der Reservierung stoppen, ein Werfen bringt also nichts, und der Eintrag ist die einzige Stelle, an der Ihr Status-Endpunkt den Fehler anzeigen kann. Das vollständige Beispiel unten sichert beide Phasen auf diese Weise ab.
Schritt 3: Jobs starten und ihre Ergebnisse lesen
Starten Sie einen Job aus beliebigem serverseitigem Code, indem Sie per POST an die Hintergrundfunktion senden. Die Job-ID erzeugt der Aufrufer selbst, denn eine 202 hat keinen Body, der eine zurückbringen könnte:
const jobId = crypto.randomUUID();
const start = await fetch("https://YOUR_SITE.netlify.app/api/solve-signup", {
method: "POST",
headers: { "x-job-secret": process.env.JOB_SECRET },
body: JSON.stringify({ jobId, email: "YOUR_EMAIL" }),
});
console.log(start.status); // 202: accepted, not solved yetEine kleine synchrone Funktion meldet den Eintrag. Sie läuft in Millisekunden, weit innerhalb des Limits von 60 Sekunden:
// netlify/functions/job-status.mjs
import { getStore } from "@netlify/blobs";
export default async (req, context) => {
const jobs = getStore({ name: "captcha-jobs", consistency: "strong" });
const job = await jobs.get(context.params.id, { type: "json" });
if (!job) return new Response("unknown or not started", { status: 404 });
return Response.json(job);
};
export const config = { path: "/api/jobs/:id", method: "GET" };Fragen Sie sie alle paar Sekunden ab. Ein Job hat keinen Eintrag, bis er fertig ist oder ein Versuch scheitert, eine 404 bedeutet also, dass sein erster Versuch noch läuft oder dass er nie gestartet ist, weil das Secret nicht übereinstimmte. Lesen Sie wie hier mit starker Konsistenz. Blobs ist standardmäßig nur letztendlich konsistent (Eventual Consistency): Ein neuer Eintrag erscheint sofort, aber eine Aktualisierung kann bis zu 60 Sekunden brauchen, bis sie jeden Edge-Standort erreicht, lange genug, dass ein inzwischen fertiger Job noch so aussieht, als würde er wiederholt.
Um den Job zeitgesteuert laufen zu lassen, greifen Sie zu einer geplanten Funktion, beachten Sie aber deren Limit: 30 Sekunden, halb so viel wie bei der synchronen. Lassen Sie sie die Arbeit an die Hintergrundfunktion übergeben und in deutlich unter einer Sekunde fertig werden:
// netlify/functions/nightly-signup.mjs
export default async () => {
const res = await fetch(`${process.env.URL}/api/solve-signup`, {
method: "POST",
headers: { "x-job-secret": process.env.JOB_SECRET },
body: JSON.stringify({ jobId: crypto.randomUUID(), email: "YOUR_EMAIL" }),
});
console.log("queued:", res.status); // 202 means accepted, not solved
};
export const config = { schedule: "@daily" };URL ist eine der schreibgeschützten Variablen, die Netlify den Funktionen zur Laufzeit bereitstellt: die Hauptadresse Ihrer Website. Geplante Funktionen werden nur in veröffentlichten Deployments ausgelöst, nicht in Deploy Previews oder Branch Deploys.
Vollständiges lauffähiges Beispiel
// npm install capskip @netlify/blobs cheerio
// netlify/functions/solve-signup.mjs
import { getStore } from "@netlify/blobs";
import * as cheerio from "cheerio";
import { CapSkip } from "capskip";
const PAGE_URL = "https://example.com/signup";
export default async (req) => {
// Anyone can POST to this URL, so check a shared secret first.
if (req.headers.get("x-job-secret") !== process.env.JOB_SECRET) return;
const { jobId, email } = await req.json();
const jobs = getStore({ name: "captcha-jobs", consistency: "strong" });
if (await jobs.get(`${jobId}-posted`)) return; // already posted once
// Phase 1: fetch and solve. Throwing here is safe: Netlify runs
// the function again after one minute, then two minutes later.
let page, $, result;
try {
if (!process.env.CAPSKIP_HOST) throw new Error("CAPSKIP_HOST is not set");
const solver = new CapSkip({
apiKey: process.env.CAPSKIP_API_KEY,
host: process.env.CAPSKIP_HOST,
port: Number(process.env.CAPSKIP_PORT ?? 8080),
});
page = await fetch(PAGE_URL);
$ = cheerio.load(await page.text());
result = await solver.recaptcha($(".g-recaptcha").attr("data-sitekey"), PAGE_URL);
} catch (err) {
const prev = await jobs.get(jobId, { type: "json" });
const attempt = (prev?.attempt ?? 0) + 1;
// The first run plus two retries: after the third, nothing reruns.
const state = attempt < 3 ? "retrying" : "failed";
await jobs.setJSON(jobId, { state, attempt, error: String(err) });
throw err;
}
// Phase 2: post the form once. Claim the job first, so a rerun
// that reaches this line finds the claim taken and stops.
const claim = await jobs.setJSON(`${jobId}-posted`, { at: Date.now() }, { onlyIfNew: true });
if (!claim.modified) return;
try {
const form = $("form").has(".g-recaptcha");
const body = new URLSearchParams(form.serializeArray().map((f) => [f.name, f.value]));
body.set("email", email);
body.set("g-recaptcha-response", result.code);
const cookie = page.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
method: "POST",
headers: { cookie, referer: PAGE_URL },
body,
});
await jobs.setJSON(jobId, { state: "done", status: res.status });
} catch (err) {
// No rethrow: a retry could not post again, so record it here.
await jobs.setJSON(jobId, { state: "failed", error: String(err) });
}
};
export const config = { path: "/api/solve-signup", method: "POST", background: true };Setzen Sie JOB_SECRET, CAPSKIP_HOST, CAPSKIP_API_KEY und, falls Sie ihn geändert haben, CAPSKIP_PORT in der Netlify UI, deployen Sie und starten Sie einen Job wie in Schritt 3. Jede Option, die der reCAPTCHA-Aufruf annimmt, einschließlich invisible und Enterprise, funktioniert hier unverändert; die reCAPTCHA-v2-Solver-Seite erklärt, was der Typ braucht.
Häufige Fehler und was sie bedeuten
| Was Sie sehen | Ursache | Beheben |
|---|---|---|
| Die Anfrage scheitert nach etwa einer Minute, und es wird kein Formular gesendet | Das Lösen läuft in einer synchronen Funktion oder in waitUntil, das deren Limit von 60 Sekunden teilt | Verlagern Sie das Lösen in eine Hintergrundfunktion |
| Ein Job-Eintrag mit der Meldung CAPSKIP_HOST is not set oder, falls Sie diese Prüfung entfernt haben, eine NetworkException mit ECONNREFUSED 127.0.0.1:8080 | CAPSKIP_HOST fehlt in der deployten Funktion, daher würde das SDK auf Loopback zurückfallen | Setzen Sie die Variable mit einem Geltungsbereich, der Functions umfasst, und deployen Sie dann erneut |
| Funktioniert mit netlify dev, scheitert nach dem Deployment | Lokal läuft die Funktion auf Ihrem eigenen Rechner, wo Loopback und Ihre lokale IP den Solver erreichen | Verwenden Sie für die deployte Website den Server-Modus und Ihre öffentliche IP |
| Eine Variable aus netlify.toml ist in der Funktion undefined | Variablen, die in netlify.toml deklariert sind, erreichen Funktionen nie | Setzen Sie sie stattdessen über die Netlify UI, CLI oder API |
| Die Funktion verwendet noch einen alten CAPSKIP_HOST | Ein Deployment behält die Werte, die beim Build gesetzt waren | Deployen Sie erneut, nachdem Sie eine Variable geändert haben |
| Eine Verbindung, die hängt, dann eine NetworkException mit ETIMEDOUT | Der Port wird nicht weitergeleitet, oder die Windows Firewall verwirft ihn | Leiten Sie den Port weiter, geben Sie ihn in der Windows Firewall frei und testen Sie ihn von außerhalb Ihres Netzwerks |
| ApiException mit ERROR_KEY_DOES_NOT_EXIST | API Key Validation ist eingeschaltet, und der Schlüssel steht nicht in der Liste von CapSkip, oder CAPSKIP_API_KEY ist nicht gesetzt, und das SDK hat seinen Standardwert gesendet | Fügen Sie den Schlüssel in CapSkip hinzu und setzen Sie die Variable |
| Das Formular wurde zweimal gesendet | Ein Fehler nach dem Senden hat eine Wiederholung ausgelöst, und nichts hat den zweiten Lauf gestoppt | Reservieren Sie den Job vor dem Senden mit onlyIfNew, wie in Schritt 2 |
| Der Status-Endpunkt liefert dauerhaft 404 | Das Secret stimmte nicht überein, daher ist die Funktion zurückgekehrt, bevor sie etwas geschrieben hat | Setzen Sie beim Aufrufer und auf der Website dasselbe JOB_SECRET |
| Die Website lehnt den Token ab | Er war älter als etwa zwei Minuten oder schon verwendet | Senden Sie direkt nach dem Lösen ab, ein Token pro Absenden |
FAQ
Kann ich das Limit von 60 Sekunden für ein Captcha-Lösen in Netlify Functions erhöhen?
Nein. Netlify bezeichnet die Limits für synchrone, geplante und Hintergrundfunktionen als fest, und waitUntil sowie Streaming-Antworten bleiben innerhalb der 60 Sekunden. Das Limit von 15 Minuten für Hintergrundfunktionen ist die lange Option, die Netlify anbietet, und es deckt die 300 Sekunden Wartezeit des SDK mit reichlich Reserve ab.
Kann eine Netlify-Funktion CapSkip auf meinem PC zu Hause oder im Büro erreichen?
Ja, über den Server-Modus. CapSkip lauscht auf Ihrer öffentlichen IP, Ihr Router leitet den Port an diesen PC weiter, und die Funktion verbindet sich über dieselbe HTTP-API, die sie auch lokal verwenden würde. Eine statische öffentliche IP sorgt dafür, dass CAPSKIP_HOST zwischen Deployments gültig bleibt. Ohne Private Connectivity können Sie Netlify nicht per Adresse freigeben, daher übernimmt API Key Validation die Zugangskontrolle.
Berechnet CapSkip Gebühren pro Lösung, wenn Netlify es aufruft?
Nein. Der Server-Modus ändert, von wo aus der Solver erreichbar ist, nicht, wer ihn betreibt: Es ist weiterhin Ihr eigener Windows-Rechner, und er zählt keine Lösungen. Netlify misst die Laufzeit von Funktionen, eine Hintergrundfunktion, die zwei Minuten auf eine Lösung wartet, verbraucht also zwei Minuten davon.
Worin unterscheidet sich das vom Betrieb auf AWS Lambda?
Die Einschränkungen unterscheiden sich. Bei Lambda hinter API Gateway ist die Grenze ein Integrations-Timeout von 29 Sekunden, und ein NAT gateway mit einer Elastic IP gibt jedem Lösevorgang eine feste Quelladresse für Ihre Firewall. Bei Netlify liegt die Grenze bei 60 Sekunden, die Hintergrundfunktion ist der eingebaute Weg darum herum, und eine feste Adresse gibt es nur mit einem Enterprise-Add-on. Den Aufbau für Lambda finden Sie im Captcha-Leitfaden für AWS Lambda.
Die Kurzfassung
Lösen Sie bei einem Captcha-Job in Netlify Functions nie in einer synchronen Funktion: Ihr Limit von 60 Sekunden ist fest, und waitUntil entkommt ihm nicht. Lösen Sie in einer Hintergrundfunktion, senden Sie das Formular im selben Lauf, solange der Token frisch ist, und reservieren Sie den Job zuerst mit einem onlyIfNew-Schreibvorgang, damit die zwei Wiederholungen von Netlify nie doppelt senden können. Melden Sie Ergebnisse aus Blobs über eine kleine synchrone Funktion. Verbinden Sie sich im Server-Modus mit CapSkip, mit Adresse und Schlüssel in Variablen mit dem Geltungsbereich Functions und eingeschalteter API Key Validation.
- Alle anderen Captcha-Typen, die das Node-Paket löst: die Node.js-Captcha-Solver-Seite.
Netlify liefert die Funktionen, und das Lösen bleibt auf einem Windows-Rechner, der Ihnen gehört. Genau dafür betreiben Sie Ihren eigenen Captcha-Löser: Der Zähler der Plattform erfasst Minuten, und nichts zählt Lösungen.
