Skip to main content

Login protection

AFCT throttles authentication to slow down password guessing and account enumeration. Two independent layers work together, plus an optional captcha step:

  • Per-account lockout, tied to the email being signed in to. Admin-configurable, and the resulting lock is persisted so it survives a restart.
  • Per-IP rate limiting, tied to the client IP. Fixed limits, held in memory.

For each sign-in attempt the strictest outcome across the buckets wins: if either the account or the IP bucket is blocked, the attempt is blocked; otherwise a challenge (captcha) or added friction (a short delay) may apply.

Relevant code: src/lib/security/rate-limiter.ts (the buckets), src/lib/login-policy.ts (the admin policy resolver), src/lib/credentials.ts (the login gate), and the User.lockedUntil column (the persisted lock).

Account lockout policy

Failed sign-ins to one account are counted in a per-account bucket keyed on the email address, over a rolling 15-minute window. Within that window the response escalates:

  1. Friction after a few failures: a short randomized server-side delay.
  2. Captcha challenge after more failures: the login form must solve an hCaptcha (only when hCaptcha is configured; see the Captcha tab in System Settings).
  3. Lockout once the attempts reach the configured maximum: the account is blocked for the configured duration.

Two parts of this are admin-configurable in System Settings → General, and override the built-in account defaults (the IP limits below stay fixed):

SettingEnv fallbackDefaultRange
Failed logins before lockoutLOGIN_MAX_ATTEMPTS103-50
Account lockout duration (minutes)LOGIN_LOCKOUT_MINUTES101-1440

The value is resolved from the SystemSettings row, then the environment variable, then the default, and is clamped to the range above so the policy can't be set loose enough to disable protection (getLoginLockoutPolicy).

Persisted lock

When an account trips the lockout, AFCT also writes the lock to the User.lockedUntil column, a future instant. The login gate rejects the attempt before checking the password whenever lockedUntil is in the future, then the lock clears itself once that instant passes (no background sweeper). Persisting it this way means a lock:

  • survives an app restart or redeploy (the in-memory counters do not), and
  • is a step toward correctness if AFCT is ever run multi-instance (the in-memory buckets are per-instance; see the caveat below).

The write is guarded so only the transition into a lock updates the row; subsequent blocked attempts write nothing. A successful sign-in clears the account and IP counters.

Administrators can end a lock early with Unlock account on the User Accounts page; that clears lockedUntil.

IP rate-limiting policy

Attempts from one client IP are counted in per-IP buckets. These limits are fixed (not admin-configurable) and are held in memory. The client IP is derived by getClientIp (src/lib/ip-utils.ts).

FlowWindowBlock afterBlock durationCaptcha at
Login (per IP)10 min20 attempts30 min14
Signup (per IP)30 min12 attempts60 min6
Signup (per email)24 h3 attempts6 h3
Email-availability check (per IP)10 min30 attempts15 minnever
Avatar upload (per user)10 min20 uploads10 minnever

The login form calls /api/auth/login-check to peek at the current state (without counting an attempt) so it can tell the user whether a failed sign-in was a captcha challenge, a temporary block, or just a wrong password. NextAuth otherwise reports only a generic error. The authoritative counting happens in the credentials authorize path.

A block outlives the window that produced it (a login IP block runs 30 minutes against a 10-minute window), so a bucket whose window has rolled over keeps any live block or challenge instead of rehydrating clean. Both the counting path and the peek honour this.

Inspecting and clearing IP limits

System Status → Rate Limits lists the IPs currently blocked or challenged on the instance serving the request, with the reason, when the restriction started, how many attempts have been counted and refused, and when it expires. An administrator can clear one early (POST /api/admin/status/rate-limits/clear), which is logged at SECURITY severity with the actor, the address, and what was in force when it was cleared.

Each address is enriched by src/lib/status/rate-limits.ts from three local sources: a pure offline classification (ip-classify.ts), a reverse-DNS name from the resolver the server already uses (attempted only for public and carrier-shared addresses, capped by an 800 ms timeout and a bounded 10-minute cache), and a single grouped ActivityLog query for the last 30 days served by the [ipAddress, timestamp] index. Enrichment is capped at 25 addresses per refresh, and a failed activity lookup degrades to knownActivity: null rather than emptying the panel.

No third-party geolocation or WHOIS service is called, deliberately: it would send visitors' addresses to an outside company, require egress plus an API key, and fail on an air-gapped install, all for a city name that answers a less useful question than "have we seen this address before".

Only the IP-keyed scopes are exposed. The buckets keyed on an email address or a user id (login:account, signup:identifier, avatar-upload) are deliberately absent, so the tab cannot be used to learn which accounts exist. Ending an account lock is a separate action on the User Accounts page.

Deployment caveat: single instance

The rate-limiter buckets live in process, per app container. This is correct for the supported deployment (a single app container) but has two consequences:

  • The IP/account counters reset on restart or redeploy.
  • They are not shared across instances. Running AFCT multi-instance would give each instance its own counters, multiplying the effective budget and making per-account lockout non-global. To scale horizontally, back the buckets with a shared store (e.g. Redis) or put a platform rate limiter in front of auth.

The persisted account lock (User.lockedUntil) is the exception: it lives in the database, so that portion already behaves correctly across restarts and instances.