Apricot Framework

Usage

The requirement model, the score tiers, what each rejection reason means, and calling the verifier without ASP.NET Core.

Requirements

An endpoint declares what it needs. Everything is off until declared, so the default accepts any genuine token from any configured instance.

RequirementDeclared byOff when
Minimum scorePolicy, or MinScore to override itPolicy is Unspecified and MinScore unset
Allowed actionsAllowedActionsthe list is empty
Allowed hostnamesAllowedHoststhe list is empty

Score tiers

PolicyThresholdProvider reports no score
Unspecified0.5accepted
Low0.2rejected score_unavailable
Medium0.5rejected score_unavailable
High0.85rejected score_unavailable

Unspecified is the only value that tolerates a score-less provider, which is why reCAPTCHA v2 and Turnstile keep working by default. Naming a tier is what turns the score into a requirement, so Medium and Unspecified share a threshold but not a meaning: the first insists on a score, the second merely applies the threshold to one it was given.

An explicit MinScore also requires a score, and overrides the tier's threshold.

Warning

Naming any tier on an endpoint served by reCAPTCHA v2 or Turnstile rejects every request, because neither reports a score. See Providers.

Actions are compared ordinally: an action is an opaque token your own front end chooses, so Sign_In and sign_in are different actions. Hostnames are compared case-insensitively, as DNS names are.

Rejection reasons

The reason travels in the error payload and is the thing a client should switch on.

ReasonMeans
unknown_providernothing matched what the request described, and no default applied
ambiguous_providerseveral instances matched and none is the default; send a site key
provider_mismatchthe endpoint pins an instance the request's description contradicts
missing_responsethe request carried no token
not_verifiedthe provider itself did not accept the token
low_scorescored, but below the threshold
score_unavailablea score was required and the provider reports none
action_mismatchthe action was not in the allow-list
action_unavailablean action was required and the provider reports none
hostname_mismatchthe challenge was served from a hostname not in the allow-list

A rejection also carries the provider's own codes, normalised — invalid_secret, timeout_or_duplicate, invalid_keys and the rest — under errors.

Rejected, versus unable to judge

These are different outcomes and they are reported differently.

  • CaptchaRejectedException — the challenge was judged and failed. The caller can act on it.
  • CaptchaException — the provider was unreachable, timed out, or answered unreadably. Nobody judged anything. An endpoint pinning an instance that is not configured also lands here, because that is a mistake in the service rather than in the request.

CaptchaRejectedException derives from CaptchaException, so catching the base catches both. Build a rejection with CaptchaRejectedException.ForReason(...); the constructors take a message, and a reason handed to one of those would be reported as prose rather than as the decision.

Without ASP.NET Core

The core package verifies a token with no host involved:

var result = await verifier.VerifyAsync(
    "Default",
    new CaptchaVerificationInput { Response = token, RemoteIp = clientAddress },
    cancellationToken);

var decision = await validator.ValidateAsync(
    new CaptchaRequirements { AllowedActions = ["sign_in"] },
    result,
    cancellationToken);

Build an ICaptchaProvider by deriving from the provider base you want and returning a client and a secret; DefaultCaptchaProviderRegistry takes the instances you constructed. Everything else — the request shape, the transport failures, the unreadable answers — is handled once in the shared base, so providers cannot drift in how they fail.

On this page