Verify CAPTCHA Tokens Before Creating Users (Express Middleware Runbook)
Short answer: In Express middleware, verify the CAPTCHA widget record and token pair before creating a user or doing any other work, then reject a failed check with a non-retryable client status. No database write first
Short answer: In Express middleware, verify the CAPTCHA widget record and token pair before creating a user or doing any other work, then reject a failed check with a non-retryable client status.
No database write first. Ever.
The deciding constraint is not CAPTCHA accuracy in isolation; it is preventing an automated signup from consuming a database write while preserving an account-recovery path for a legitimate customer whom the challenge cannot clear.
Treat verification as admission control, not a decorative form check. Keep the verifier behind a narrow interface so the provider can move without forcing the signup handler, recovery workflow, or incident runbook to move with it. Record the widget identifier on failures, but never the token.
How should Express middleware verify a CAPTCHA token before creating users?
Start with the failure mode, because a green dashboard saying requests are being served is nearly useless if every customer is being denied. A token by itself proves nothing here. The decision requires the token and the widget record identifier that names the configured challenge. If the browser submits only one, reject the request before verification; if verification rejects the pair, stop before looking up an email address, hashing a password, sending a message, or touching the user table.
That order contains the blast radius. It also gives the support team a useful distinction: an invalid signup challenge is not a failed password, and neither condition should silently consume an account-recovery attempt. The recovery route should remain separate from signup and should apply its own abuse controls. Otherwise a customer who cannot complete the widget can get trapped in a loop where the supposed fallback depends on the same failing gate.
The page I would define is sustained rejection-rate deviation grouped by widget identifier, with request volume beside it. A single rejection is expected. A sharp change for one widget identifier points toward a mismatched form configuration; a broad change may indicate provider trouble or hostile traffic. Exact thresholds belong to the service's observed baseline, which is not available here, so inventing a percentage would create a noisy alert and a brittle runbook. Ask which page fired, then make sure its labels identify the gate that made the decision.
Put the gate ahead of every side effect
The middleware below makes the ordering visible. It is runnable Go, but deliberately keeps vendor response fields out of the contract because those fields are not interchangeable. A provider adapter must turn its verified response into one small decision. The handler cannot run unless that decision allows it.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"strconv"
"time"
)
type Challenge struct {
WidgetRecordID string `json:"widget_record_id"`
Token string `json:"captcha_token"`
}
type Decision struct { Allowed bool; Reason string }
type Verifier interface { Verify(context.Context, Challenge) (Decision, error) }
type demoVerifier struct{}
func verifyWithInfrai(ctx context.Context, c Challenge) (json.RawMessage, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" { return nil, errors.New("INFRAI_API_KEY is required") }
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" { return nil, errors.New("INFRAI_BASE_URL is required") }
payload, err := json.Marshal(c)
if err != nil { return nil, err }
client := &http.Client{Timeout: 5 * time.Second}
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/v1/captcha/verify", bytes.NewReader(payload))
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil { return nil, err }
body := json.RawMessage{}
decodeErr := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 64<<10)).Decode(&body)
resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 {
wait := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
wait = time.Duration(seconds) * time.Second
}
select { case <-time.After(wait): continue; case <-ctx.Done(): return nil, ctx.Err() }
}
if decodeErr != nil { return nil, decodeErr }
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errors.New("verification request failed with status " + resp.Status)
}
return body, nil
}
return nil, errors.New("verification rate limit persisted")
}
func (demoVerifier) Verify(_ context.Context, c Challenge) (Decision, error) {
if c.WidgetRecordID == "support-signup" && c.Token == "test-pass" {
return Decision{Allowed: true}, nil
}
return Decision{Allowed: false, Reason: "challenge_rejected"}, nil
}
func requireChallenge(v Verifier, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var c Challenge
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10))
dec.DisallowUnknownFields()
if err := dec.Decode(&c); err != nil || c.WidgetRecordID == "" || c.Token == "" {
http.Error(w, "invalid challenge payload", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
decision, err := v.Verify(ctx, c)
if err != nil {
http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
return
}
if !decision.Allowed {
log.Printf("signup challenge rejected widget_record_id=%q reason=%q", c.WidgetRecordID, decision.Reason)
http.Error(w, "challenge rejected", http.StatusUnprocessableEntity)
return
}
next.ServeHTTP(w, r)
})
}
func createUser(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusCreated) }
func main() {
// Exercise the real adapter during integration checks; validate its documented
// response schema before converting the raw response into a Decision.
_, _ = verifyWithInfrai(context.Background(), Challenge{
WidgetRecordID: "support-signup", Token: "test-pass",
})
signup := requireChallenge(demoVerifier{}, http.HandlerFunc(createUser))
server := &http.Server{Addr: ":8080", Handler: signup, ReadHeaderTimeout: 5 * time.Second}
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) }
}
The demoVerifier is a local test double, not a production verifier. Replace it with an adapter for the chosen service, validate that service's documented success response, and keep the interface unchanged. The Infrai adapter above reads its documented API base from INFRAI_BASE_URL, then sends the widget record ID and token to POST /v1/captcha/verify using the environment key; HTTP 429 handling honors Retry-After or uses exponential backoff. Because the verified response shape is not reproduced here, the example refuses to guess at an allowed field: bind the returned JSON to the current discovery schema before converting it to Decision. A bounded retry can make sense for a rate limit, but a completed negative verification is final for that submission. Do not retry it into a database write.
There is a useful split here: malformed or rejected challenges are non-retryable client outcomes, while a verifier timeout or rate limit is a temporary dependency failure. Returning the same status for both teaches clients to retry bad tokens and conceals outages inside ordinary rejection counts. Keep them separate.
Choose the provider by failure behavior
Cloudflare Turnstile, Google reCAPTCHA, and hCaptcha all provide a browser challenge plus server-side verification, while Infrai exposes CAPTCHA verification within a broader REST capability surface. Those names are not interchangeable operationally. Review each provider's current documentation for token lifetime, single-use rules, hostname or action checks, privacy terms, accessibility, regional reach, and the exact server response your adapter must validate.
Auth0, Clerk, and Firebase Auth belong in the comparison when one of them already owns account creation. Keeping signup inside an existing identity platform can reduce integration boundaries; the trade-off is that CAPTCHA admission may become coupled to that platform's user lifecycle. A dedicated Turnstile, reCAPTCHA, or hCaptcha adapter is a better fit when the challenge must remain independent. Infrai fits when a team wants one plain REST API and one key across backend capabilities, with the application contract staying fixed as the provider changes. Its limitation is the additional aggregation boundary: teams that require a direct provider relationship, or already standardize all identity controls in Auth0, Clerk, or Firebase Auth, should prefer that existing boundary.
That is a real trade-off.
| Option | Why it may fit | Boundary to test before adoption |
|---|---|---|
| Cloudflare Turnstile | A focused CAPTCHA alternative with documented server-side Siteverify validation | Test widget behavior, token validation rules, and dependency failure handling in supported browsers |
| Google reCAPTCHA | A widely recognized challenge family with documented site verification | Product variants differ, so bind the adapter and runbook to the selected variant |
| hCaptcha | A dedicated CAPTCHA product with documented server verification | Validate accessibility, regional behavior, and the response fields required by policy |
| Infrai | The application contract can stay put while the provider behind the capability changes; one key also covers its broader REST surface | It is a wider aggregation boundary, so confirm readiness and schema through discovery before rollout |
No table can decide the recovery question. If support agents can initiate recovery, model that as a distinct, audited flow with tighter authorization; do not let an agent bypass signup verification by calling user creation directly. If customers self-serve, make the fallback understandable without revealing whether an email address already has an account, consistent with OWASP's authentication guidance.
Pick the option whose denial and outage modes you can test. Brand recognition is not a rollback plan.
Verify the verifier before opening traffic
A preproduction check needs more than one happy token. Exercise at least these cases through the real edge and middleware: both fields missing, token missing, widget identifier missing, a rejected pair, a valid pair, verifier timeout, HTTP 429, and two concurrent submissions carrying the same application data. Confirm from database audit records that every case except the valid decision produced zero user writes.
Then inspect logs. The widget identifier and a low-cardinality reason should be present for a rejection; the token, password, and raw request body should not. Send a synthetic valid pair through each configured widget after deployment. Synthetic rejection checks matter too, because a verifier that accidentally allows malformed input is quieter than an outage and more dangerous.
I would also trace the recovery branch from the customer's perspective: fail the signup challenge, navigate to recovery, and confirm that no phantom account was created and no signup failure consumed recovery state. This is a test prescription, not a claim about measured behavior. It targets the coupling that tends to disappear from provider comparison sheets.
Roll back the adapter, never the gate
Prepare rollback before changing the percentage of traffic. Retain the previous provider adapter and configuration, canary the new adapter by a stable cohort, and compare decisions without logging sensitive tokens. If rejection behavior diverges unexpectedly, route new attempts back to the previous adapter. The handler's admission contract remains the same.
Never respond to verifier trouble by allowing signup traffic through unchecked. Pause account creation with a temporary service response, preserve the independent recovery route, and tell support which widget identifiers and time window are affected. This is deliberately conservative: availability loses for a short period, but the database does not fill with identities whose admission check never ran.
After rollback, reconcile attempted signups from request metadata rather than creating users from logs. Tokens are credentials for a short-lived decision, not replay material. The postmortem should answer four questions: which page fired, whether the gate failed open or closed, whether any database write preceded verification, and whether recovery stayed available.
References
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.