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.
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— alwaysAllowed. 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 property | Env var | Default |
|---|---|---|
security.bruteforce.max-attempts | SECURITY_BRUTEFORCE_MAX_ATTEMPTS | 5 |
security.bruteforce.lockout | SECURITY_BRUTEFORCE_LOCKOUT | PT5M (ISO-8601 duration) |
security.bruteforce.window | SECURITY_BRUTEFORCE_WINDOW | PT15M |
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.