Lockout & Captcha
Two building blocks against password guessing and sign-up bots: IFailureLockout locks a key after repeated failures, ICaptchaVerifier checks a captcha token on the server. Both are registered by default and do nothing until you call them (the captcha is off until it has a secret). Pair them with HTTP rate limits on the same endpoints.
Failure Lockout
public interface IFailureLockout
{
ValueTask<TimeSpan> LockedForAsync(string key, CancellationToken ct = default);
ValueTask<bool> RecordFailureAsync(string key, int maxFailures, TimeSpan window, TimeSpan lockFor, CancellationToken ct = default);
ValueTask ClearAsync(string key, CancellationToken ct = default);
}
RecordFailureAsync returns true when this failure reached maxFailures within window and locked the key for lockFor. You choose the keys and the numbers. A sign-in, for example:
public sealed class LoginService(IFailureLockout lockout, IPasswordHasher hasher, Accounts accounts)
{
static readonly TimeSpan Window = TimeSpan.FromMinutes(15), LockFor = TimeSpan.FromMinutes(15);
public async Task<Account> LoginAsync(string name, string password, string clientIp)
{
var userKey = $"login:{name.ToLowerInvariant()}|{clientIp}"; // this name from this address
var ipKey = $"login-ip:{clientIp}"; // every name from this address
var locked = Max(await lockout.LockedForAsync(userKey), await lockout.LockedForAsync(ipKey));
if (locked > TimeSpan.Zero)
throw new HttpApiException(429, "locked", "Too many failed attempts. Try again later.",
retryAfterSeconds: (int)Math.Ceiling(locked.TotalSeconds));
var account = await accounts.FindAsync(name);
if (account is null || !hasher.Verify(password, account.PasswordHash))
{
await lockout.RecordFailureAsync(userKey, 5, Window, LockFor);
await lockout.RecordFailureAsync(ipKey, 20, Window, LockFor); // credential spraying
throw new HttpApiException(401, "invalid_credentials", "Invalid username or password.");
}
await lockout.ClearAsync(userKey); // not the address budget: one valid account must not reset it
return account;
}
}
Note:
Never lock by user name alone: anyone could keep the owner locked out by failing on purpose. Combine the name with the client address, and add a budget per address.
Shared by the fleet
altruist:
security:
lockout:
store: backplane # memory (default) | backplane
memory keeps exact sliding failure windows per server. backplane keeps the failure count and the lock in the fleet backplane (Redis), so a lock set on one server holds on every other; the failure window starts at the first failure. Keys are stored hashed. While the backplane is process-local or unreachable, the server counts in memory.
Captcha
altruist:
security:
captcha:
provider: turnstile # turnstile (default) | hcaptcha | recaptcha
site-key: "0x4AAAA..." # public: your client renders the widget with it
secret-key: "" # secret: set it from the environment
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterRequest body, [FromServices] ICaptchaVerifier captcha)
{
// cheap validation first: the check is a network round trip
if (captcha.Enabled && !await captcha.VerifyAsync(body.CaptchaToken ?? "", HttpContext.ClientIp()))
throw new HttpApiException(400, "captcha_failed", "The security check failed. Please try again.", "captcha");
...
}
[HttpGet("config"), AllowAnonymous] // tell the client whether to show the widget
public object Config([FromServices] ICaptchaVerifier captcha) => new { captchaSiteKey = captcha.SiteKey };
- Off without a secret:
Enabledis false,SiteKeyis null andVerifyAsyncreturns true — handy in Development. Refuse to boot in Production yourself if a captcha is required there. - Siteverify: posts
secret,responseandremoteip(when known) and accepts"success": true. Turnstile, hCaptcha and reCAPTCHA share this form;verify-urlpoints at any other compatible endpoint. - Fails closed: an unreachable or failing endpoint rejects the token (logged). Empty tokens and tokens over
max-token-length(2048) are rejected without a call;timeout-seconds(10) bounds the call.
Keep the secret out of config.yml: ALTRUIST__SECURITY__CAPTCHA__SECRET_KEY, or reference the variable your deploys already set with a placeholder: secret-key: "${MYGAME__TURNSTILE__SECRET_KEY}".