Skip to content
Flag of Europe
Made in the European Union · Independently built · Released under EUPL 1.2
Brute-Force Protection

Brute-Force Protection

A login form that returns 401 Unauthorized indefinitely is an invitation. LoginAttemptPolicy adds a configurable throttle in front of authentication — same policy, applied identically to the Vaadin login form, the REST login endpoint, and the bootstrap admin-creation endpoint.

Status: implemented. Feature §3 in Konzept-V00.60.00.md. Delivered 2026-05-10; UI ships in both Vaadin demos.

The contract

public interface LoginAttemptPolicy {
  LoginAttemptDecision beforeAttempt(LoginAttemptContext context);
  void onSuccess(LoginAttemptContext context);
  void onFailure(LoginAttemptContext context);
}

LoginAttemptDecision is sealed — only two outcomes:

public sealed interface LoginAttemptDecision {
  record Allowed() implements LoginAttemptDecision {}
  record LockedOut(Duration retryAfter, int failedAttempts)
      implements LoginAttemptDecision {}
}

The context carries username and remoteAddress (or whatever identifier the adapter has). The policy decides whether to allow the attempt, and tracks success/failure to update its internal state.

Defaults

  • NoopLoginAttemptPolicy — always Allowed. Useful for tests.
  • InMemoryLoginAttemptPolicy — counts failures per username+IP, applies an exponential lockout window. State is local to the JVM (not cluster-aware — see Roadmap).

Configuration is loaded via LoginAttemptConfigurationLoader with the familiar sysprop → env → default chain:

System propertyEnv varDefault
security.bruteforce.max-attemptsSECURITY_BRUTEFORCE_MAX_ATTEMPTS5
security.bruteforce.lockoutSECURITY_BRUTEFORCE_LOCKOUTPT5M (ISO-8601 duration)
security.bruteforce.windowSECURITY_BRUTEFORCE_WINDOWPT15M

Vaadin lockout UI

MyLoginView.reactOnFailedLogin(...) in both Vaadin demos re-queries LoginAttemptPolicy.beforeAttempt(...) with the entered username and the VaadinRequest’s remote address. When the policy now returns LockedOut, the form shows a red top-centre notification instead of the generic “credentials not accepted” toast:

Account locked — 5 failed attempts. Try again in 4 min

A helper formatDuration(Duration) renders the retry-after window in human-readable units (s / min / h).

REST 429 + Retry-After

The REST adapter returns:

HTTP/1.1 429 Too Many Requests
Retry-After: 300
Content-Type: text/plain

Too many failed login attempts. Try again later.

Same policy, same numbers, machine-readable header. The Retry-After value is the integer seconds from LockedOut.retryAfter().

Bootstrap is protected too

The POST /api/bootstrap/admin endpoint runs through the same policy. A misconfigured first-run installation can’t be brute-forced into accepting any token: every rejected token counts as a failed attempt and locks the endpoint for the configured window.

Audit integration

Every LockedOut outcome emits a BruteForceLimitReached audit event via SecurityAuditService — visible in the Vaadin /audit view and through GET /api/audit. See Security Audit.

Plugging in your own

Implement LoginAttemptPolicy, register it in META-INF/services/com.svenruppert.vaadin.security.bruteforce.LoginAttemptPolicy. The Vaadin login form, the REST login filter, and the bootstrap endpoint all use whatever the resolver returns — no per-call-site wiring.