Rate limiting login in SvelteKit
Brute-force protection is one of those things every auth system needs but most starters skip. The default advice — "just use Redis" — is an incomplete answer for a small SaaS starter that ships at $0. The pattern below adds real brute-force protection with no external dependencies, a seam for growth, and deterministic tests.
The interface: three methods, one contract
The limiter exposes exactly three methods — check, penalize, and reset — plus a Verdict type that carries a boolean and an approximate retry-after value:
export interface RateLimiter {
/** Read-only — never records an attempt. */
check(key: string): Verdict;
/** Record a failed attempt. Returns the (possibly newly-blocked) verdict. */
penalize(key: string): Verdict;
/** Clear state for a key (call on successful auth). */
reset(key: string): void;
}This is a seam, not an implementation. The in-memory version ships as the default; swap the body for Redis or SQL when you hit multi-instance. The rest of the app never changes.
Why the pre-hash check matters
The most important thing about this limiter's placement is when it runs: before the password hash. A bcrypt/argon2/scrypt call costs tens or hundreds of milliseconds. If your rate limiter runs after the hash, a blocked attacker still burns CPU on every attempt. Moving the check upstream means blocked requests are rejected in microseconds — the expensive operation never runs.
The action sequence is: check() → if blocked, return 429 with retryAfterMs → otherwise, proceed with hashing → on hash failure, penalize() → on hash success, reset().
Sliding window vs. fixed window
A fixed-window limiter resets at the top of each hour or minute, which creates a predictable burst window: an attacker who hits the limit at 11:59 gets a full fresh quota at 12:00. A sliding window tracks each attempt's individual timestamp and expires them as time passes.
The shipped implementation stores timestamps in ascending order, prunes any that fall outside the window on each operation, and counts the survivors against the limit. This means the block state slides smoothly with real time rather than jumping at calendar boundaries.
function prune(key: string): number[] {
const cutoff = now() - windowMs;
const list = (attempts.get(key) ?? []).filter((t) => t > cutoff);
if (list.length === 0) attempts.delete(key);
else attempts.set(key, list);
return list;
}The check() method calls prune() but does not record anything — it's a pure read. The penalize() method prunes, pushes a new timestamp, and returns the verdict.
Key design: what you combine matters
Two key helpers ship with the limiter:
export function loginAttemptKey(ip: string, email: string): string {
return `login:${ip}:${email.trim().toLowerCase()}`;
}
export function signupAttemptKey(ip: string): string {
return `signup:${ip}`;
}Login keys combine IP and normalized email — so a credential-stuffing bot hitting one email from one IP gets blocked, while the real user logging in from a different IP is unaffected. Signup keys use IP only — throwaway email addresses are infinite, so the only stable signal is the source.
The email normalization (trim + lowercase) prevents User@Example.COM and user@example.com from having separate quotas.
Deterministic testing with a fake clock
Time-dependent code is fragile in tests unless time is controllable. The limiter accepts an injectable now() function — tests pass a counter that only advances via an explicit tick() call:
function fakeClock() {
let t = 1_000_000;
return {
now: () => t,
tick: (ms: number) => { t += ms; }
};
}This makes sliding-window expiry testable at millisecond granularity without real sleeps — the test that verifies "attempt 1 slides out of the window after exactly 60,000ms (the 60s window elapses)" runs in microseconds.
Eviction: safety against spoofed-key floods
An attacker can generate thousands of unique keys (random emails, IPv6 rotations) to exhaust memory. The limiter tracks insertion order via Map and evicts the oldest keys when tracked count exceeds maxKeys (default 10,000). This is a deliberate tradeoff: under extreme spoofed-key floods, very old legitimate keys may be evicted early, but the alternative is unbounded memory growth.
Honest scope: what this is and isn't
- The in-memory limiter is per-process. It stops credential stuffing from a single source against a single instance. In a multi-instance deployment behind a load balancer, different instances have different memory — a key blocked on instance A is not blocked on instance B.
- For multi-instance protection, implement the same
RateLimiterinterface against a shared store (Redis, or the SQL database itself). The seam means zero changes elsewhere. - The limiter does not log failed attempts — it doesn't write to the audit trail. If you need an audit of auth failures, wire that into the action that calls
penalize(), not into the limiter itself. - The retry-after value is approximate: it's derived from the oldest surviving timestamp, so it can overshoot slightly if the window is nearly empty. It's honest, not precise.
Production checklist
- Only FAILED attempts are recorded — successful auth resets the key, so a real user who mistypes once never hits themselves.
- Key design matters: per-IP+email for login (so a credential-stuffing bot hitting the same email from one IP gets stopped, while the real user on a different IP is unaffected), per-IP for signup (so the same IP can't brute-force throwaway addresses).
- Sliding window, not fixed window — a fixed window resets at the top of the hour, which means an attacker gets a fresh quota exactly when the clock ticks. The sliding variant tracks each attempt's timestamp and expires them individually.
- Pre-check before password hashing: the limiter runs BEFORE bcrypt/scrypt, so blocked requests never touch the expensive hash at all — a real DoS mitigation, not a timing side-channel.
- The 429 response includes an approximate retry-after value derived from the oldest surviving attempt in the window, so clients can back off honestly instead of hammering blindly.
- A read-only `check()` method exists separately from `penalize()` — UI can show a countdown without recording anything. A pure view operation should never have side effects.
- Eviction is a safety valve: when spoofed-key floods create thousands of unique keys, the oldest-inserted keys are evicted once tracked keys exceed a configurable cap (default 10,000). Unbounded memory is not an option.
- The interface (`check`, `penalize`, `reset`) is the seam — the shipped implementation is in-memory and per-process. For multi-instance deployments, write a Redis-backed or SQL-backed version of the same three methods; no other code changes needed.
- Fake-clock testing makes time deterministic: the limiter accepts an injectable `now()` function, so tests advance a counter instead of sleeping. The sliding-window expiry is verified at millisecond granularity.
- Key-building helpers (`loginAttemptKey`, `signupAttemptKey`) normalize emails to lowercase and trim whitespace — ` User@Example.COM ` becomes `user@example.com`, so the same real address doesn't get two separate quotas.
Related guides: Multi-tenant auth architecture · Hashed session tokens · RBAC & role hierarchy
Related reading
- Hashed Session Auth — DB-backed sessions with instant revocation
- Multi-tenant Authentication — application-layer tenancy patterns
- RBAC & Role Hierarchy — permission matrix and enforcement
- Product page — features, pricing, FAQ
- All documentation — guides, deep dives, and evaluations
Get in touch
Questions about the product, team licenses, or anything else? We'll respond within 48 hours.