The first time I saw one IP hit the login endpoint 300 times in a minute, I knew this site would not dodge the abuse lesson. Register, login, upload, post, like — all working, traffic growing — but someone was already running dictionary attacks, flooding the AI endpoints with throwaway accounts, and script-bombing the upload endpoint, burning model quota and climbing the OSS bill within minutes.
These all share a name: abuse. Its shapes differ, but the underlying logic is the same — some resource of yours — accounts, endpoints, compute, storage — is being consumed at a frequency far beyond legitimate use. The hard part about fighting abuse is that there's no silver bullet: you can't block everything, because real users need those endpoints too; you can't not block, because the cost is real.
What this article dissects is the defense actually running in production. It's not a single middleware — it's a five-layer closed loop: token buckets cap the peak, abuse scoring tracks the accumulation, tiered penalties auto-escalate, a Filter-level fast block catches the banned at the door, and restart recovery guarantees bans survive a reboot. Each layer solves a distinct problem, and Redis ties them together. Read it and you can copy it — and understand why "banning a user" is never as simple as flipping a switch.
The first question anti-abuse must answer: how many times per minute should this endpoint be callable? Login at 5 or 50? Upload at 10 or 100? AI generation at 1 or 10? The answers differ, and so do the risks. So layer one is not one global rate limit — it's a per-endpoint frequency budget.
This project uses bucket4j's token-bucket model. The core is an annotation:
@BucketRateLimit(
key = "user_login",
dimension = RateLimitDimension.BOTH,
permitsPerMinute = 5,
permitsPerHour = 50,
burst = true,
message = "Too many login attempts, please try again later"
)
Break down those five parameters and you have a complete rate-limit vocabulary:
key is the endpoint's "business identity": user_register, picture_upload, like_do. It's not just for logs — it drives this system's sharpest trick: different endpoints get different weights (we'll get to that in the scoring layer).
dimension decides the granularity: by IP, by user, or both. IP guards anonymous attack surfaces (endpoints callable without login); USER guards post-login abuse (like spam-liking); BOTH is belt and suspenders. Note the implementation detail: the user dimension reads the login user ID from a request attribute and falls back to IP when missing — anonymous users have no user ID, but they always have an IP to limit.
permitsPerMinute / permitsPerHour are multi-tier. Limiting per minute isn't enough — an attacker can just go slow and accumulate a few hundred calls over an hour. So the same bucket stacks minute and hour budgets: the minute tier stops bursts, the hour tier stops slow grinders.
burst controls whether the bucket can "overdraft": burst mode uses Refill.greedy, allowing short peaks to spend future tokens; strict mode uses intervally, refilling a fixed amount per tick. High-risk endpoints like login don't burst; read-heavy endpoints can — loosen where you should, tighten where you must.
message is what users see when the limit trips. Don't underestimate it — the frontend takes error code 42900 and surfaces this message directly. Written well, users know "you're going too fast"; written poorly, users think your site is broken.
The limit is woven in via an AOP @Around aspect — zero intrusion into business code. The aspect first checks the admin whitelist — admins must never lock themselves out, the first iron rule. Then buildKey assembles the limit key by dimension (user_login:ip:1.2.3.4 or user_login:user:123), and tryConsume attempts to take a token.
Buckets are cached in a Caffeine local cache — evicted after 10 minutes idle, capped at 100,000 buckets. An important engineering trade-off: local buckets (fast, no network) + a Redis-stored throttle multiplier. When a user or IP is penalized, their bucket capacity shrinks by a multiplier read from Redis. You'll see shortly that this multiplier is exactly the escalation layer's "executor."
Token buckets solve "does this single call exceed the rate?" But they have a blind spot: slow abuse. An attacker throttles below the limit every time but keeps at it all day — invisible in any single window, yet cumulatively hammering the endpoint. That's where layer two comes in: record every limit hit, and keep a cumulative ledger.
When the rate-limit aspect trips, it calls a scoring service that atomically books the hit with a Redis Lua script. The script does four things:
First, accumulate counts across four windows — 1 minute, 5 minutes, 1 hour, 24 hours, each an INCR key with its own TTL. Four windows mean "look at the near term and the trend": 200 hits in the 24h window says more than 5 hits in the 1m window.
Second, weight each hit — this is where layer one's keys earn their keep. Every endpoint has a weight config: register 10, login 5, upload 2, AI endpoints 5, like 1.5, plain query 1. Why? Because different endpoints cost different amounts when abused: registering an account may be step one of account farming, AI endpoints burn real compute money, and like-spamming enables brigading. Weight is "how many plain calls is one call here worth."
Third, compute the total score — members are written to a ZSET as endpoint:weight:timestamp, and ZRANGEBYSCORE sums the weighted total over 24 hours. One detail: the member carries a microsecond timestamp to prevent same-second collisions at equal weight, so every hit counts.
Fourth, cap the ledger — when the ZSET exceeds 5,000 members, the oldest are pruned, keeping the ledger from becoming a memory hole itself.
Why Lua? Because "book one hit + compute the total" must be atomic. Without atomicity, two concurrent requests can both read the stale total, both trigger escalation, or overwrite each other's counters. Lua runs single-threaded inside Redis, so it's naturally serial — this is the correctness cornerstone of the scoring system.
Scoring itself has no consequences; it just keeps books. What happens next is decided by the next layer.
Ledger and score in hand, here's the heart of the system: how does a score become a punishment? The answer is a six-level ladder, each level a different force of action:
L1 Notice threshold 10 — in-app + WebSocket alert, no action
L2 Throttle threshold 30 — Redis throttle multiplier, bucket shrinks to 50% (20% for severe)
L3 IP ban threshold 60 — level key written, Filter blocks with 403
L4 Account freeze threshold 120 — user_role=ban in DB + ban record
L5 Long ban threshold 300 — 7-day ban, recidivism multiplies
L6 Permanent ban threshold 600 — permanent, 1-year Redis TTL as backstop
Several decisions in this ladder are worth savoring:
Below is the complete log of a real IP ban triggered by a script — three phases at a glance: first the validation phase (tokens being spent), then the token bucket empties into rate limiting, and after 12 × 42900 (weight 5) accumulates 60 points, it auto-escalates to L3, after which every request is 403'd by the Filter:

First, thresholds span 10 to 600 — a 60x range. Because the path from "remind" to "ban" must have ample buffer. 10 points might just be a user fat-fingering buttons; 600 points means sustained, deliberate malicious action — dozens of real hits in between, squeezing false positives to near zero. The point of tiers isn't "light to heavy punishment"; it's "minimal false positives to zero tolerance."
Second, IP and user are independent ledgers with different ceilings. IP tops out at L3 (IP ban); user goes to L6 (permanent ban). The logic is plain: IPs are shared — a NAT egress may sit behind hundreds of legitimate users, so IP bans must be conservative; users are unique, so account behavior can be punished hard. Banning an IP is "stopping the bleeding"; banning a user is "removing the root." Different positions, different force.
Third, L2's penalty isn't a ban — it's "throttling." The most elegant move in the whole system: don't lock the user out, shrink their bucket capacity instead — legit users feel nothing, but a script's request rate instantly becomes unusable. The multiplier is written to Redis (abuse:throttle:user:123 = 0.5) and read by the rate-limit aspect each call. Throttling is a "soft penalty" — it gives the user a chance to simply stop, rather than being cut off at the knees.
Fourth, recidivism multiplies. Each penalized dimension keeps a 90-day recidivism counter, and the penalty duration grows as multiplier^(count-1) (capped). A first L3 might ban 30 minutes; repeat offenders get hours, then days — scripts that figure "whatever, the ban means nothing" get no second chance. Combined with a 30-minute cooldown, the same dimension won't re-trigger escalation spam within the window.
Fifth, punishment isn't the endpoint — notification closes the loop. Every escalation sends an in-app notification plus a WebSocket abuse_action push: "your behavior triggered the abuse protection," with level, reason, and unblock time. This isn't gratuitous — users knowing why they're blocked is the first line of defense against false-positive disputes, and it's how real users know to stop.
Layers 1–3 are bookkeeping and throttling; the hard intercept lives in layer four: a Filter that runs at the very front of the request chain. Every request first checks whether this caller should be blocked.
The logic is direct: if logged in, look up abuse:level:user:{id}; anonymous users look up abuse:level:ip:{ip}. If level >= 3, return 403 immediately, with ban level and unblock timestamp:
{"code":40302,"message":"Your IP is temporarily banned for excessive access","level":3,"unblockAt":1730000000}
When it actually fires, this is exactly what the frontend receives — code, human-readable message, ban level and unblock timestamp, all present:
A few details here were learned the hard way:
Admin exemption. The ban check skips admin roles. Obvious in hindsight, but if you miss it, one bad tuning pass locks your own ops team out of the admin panel.
Auth path whitelist. Login, register, captcha — these endpoints are exempt from bans. Why? Because banned users must be able to log in — if they can't, they can never see "you're banned" and can never appeal. The whitelist is "leaving a door open for users."
Redis-loss fallback. Ban state lives mostly in Redis (fast, natural TTL expiry), but if Redis restarts, bans vanish. So there's a fallback: the user's role is already ban (the ban was persisted to DB), and when Redis misses, the role check kicks in and treats it as L4. Memory can be lost; databases cannot — bans must be persisted, and memory is just an accelerator.
Unblock time. The response carries unblockAt, letting the frontend show "unblocked in X hours" — it gives users something to wait for and dramatically cuts "why can't I do anything" support tickets.
Since we're on the subject, let's unpack a full user ban — it's more steps than you'd think:
// 1. Update DB: role to ban — survives restarts
user.setUserRole(BAN_ROLE);
userMapper.updateById(user);
// 2. Write Redis level key: Filter intercepts fast
redis.set("abuse:level:user:" + userId, "4", ttl);
// 3. Keep a record: UserBanRecord upsert (one active record per user)
// guarded by a Redisson distributed lock against concurrent duplicates
Each step has a distinct job: the DB update is persistence (the database is the source of truth); Redis is speed (millisecond checks in the Filter); the record is audit — who, when, why, how long — all queryable and releasable by admins.
After the ban fires, here's what the Redis state looks like — four-window counters, weighted total score, ban level, throttle multiplier and recidivism counter, all present:
The record table is wrapped in a Redisson distributed lock for upsert — only one thread can write a ban record for the same user at once, preventing concurrent triggers from duplicating rows. If the lock isn't acquired, skip (another thread is handling it) — the classic "lock against duplicates, skip if locked."
IP bans keep records the same way, just keyed by IP. IP records also support replace-on-escalation: if the same IP already has an active L3 record and escalates, the old one is invalidated and a new row inserted — one active record per IP, clean for querying and management.
By now you may have noticed the tension: ban state lives mostly in Redis (fast, volatile), records live in the DB (stable, slow). So what happens when Redis restarts? The answer: rehydrate on startup.
AbuseBootstrapLoader listens for the application-ready event, pulls all active ban records from the DB, computes remaining TTL for each, and writes them back to Redis. Expired ones are invalidated on the way. This gives the whole system eventual consistency: the DB is the source, Redis is the mirror, and at any moment a lost Redis can be rebuilt from the DB.
One more subtlety: a "permanent" ban (L6) isn't truly permanent in Redis — it gets a 1-year TTL backstop. Doesn't matter, because the record is permanent and even after the Redis key expires a year later, the Filter's role fallback (ban role = treat as L4) covers it. Two backstops interlock — if one fails, the other holds.
No matter how solid the backend is, without frontend cooperation it's wasted. This project's axios interceptors handle two error codes uniformly:
42900 (too many requests) — show message.warning with the backend's message directly: "Too many login attempts, please try again later." Instantly understood. One more detail: the login page's captcha auto-refreshes after a limit trip — consecutive failed logins are likely a machine, and refreshing the captcha adds one more obstacle.
40302 (IP/account restricted) — show a notice with the unblock time when available. Users know "the site isn't broken; I'm restricted, and I'll be back in X hours."
The frontend also throttles the global rate-limit toast: when several endpoints trip 42900 at once, only one toast per 5 seconds — rate limiting is meant to stop abuse; the alert itself must not become a new nuisance.
The most copyable engineering decision in this system is that every policy parameter lives in config; the code only carries the mechanism.
abuse:
enabled: true
default-weight: 1.0
cooldown-minutes: 30
endpoint-weights:
"user_register": 10.0 # prevent account farming
"user_login": 5.0 # prevent credential stuffing
"picture_upload": 2.0 # prevent storage bombing
"ai_change_bg": 5.0 # prevent compute draining
"like_do": 1.5 # prevent bot likes
"post_add": 3.0 # prevent spam posting
"picture_list_vo": 1.0 # plain queries
thresholds: { notice: 10, warn: 30, ip-ban: 60, freeze: 120, long-ban: 300, perm-ban: 600 }
durations: { warn: 300, ip-ban: 1800, freeze: 86400, long-ban: 604800 }
recidivism: { enabled: true, multiplier: 2, max-multiplier: 8 }
Weights, thresholds, durations, recidivism — all config. The ops team can tune thresholds without redeploying: loosen early (against false positives), tighten later (against exploits) — that kind of dynamic tuning is only feasible when policy is data.
This config also reveals the platform's security values: query endpoints weigh 1 (browsing doesn't matter), registration weighs 10 (account farming is the root of evil), AI weighs 5 (compute is money). Anti-abuse isn't "lock everything down equally" — it's allocating protection by resource cost — the costlier the resource, the tighter the guard.
A few pitfalls from production deserve their own entries.
Shared-IP false bans. Early on, the IP threshold was too low, and a whole office behind one NAT egress got banned. Later we capped IP at L3 and raised the threshold. Lesson: IPs are shared; banning an IP must be far more conservative than banning a user.
Admins banned themselves. One tuning pass forgot the admin exemption and locked everyone out of the admin panel. Now it's an iron rule in the comments. Lesson: every protection system needs a backdoor for its own people, from day one.
Duplicate records. Concurrent triggers wrote two active records for the same user. Fixed with a Redisson-locked upsert. Lesson: any "write a unique record" logic must consider concurrency, doubly so in a distributed system.
Notification spam. Every escalation sent a notification, and one persistently attacked user got dozens of warnings. Fixed with a 30-minute cooldown. Lesson: side effects like notifications need throttling, or the protection system itself becomes a nuisance source.
Bans lost on Redis restart. Early on, a Redis restart wiped all bans and attackers were instantly back. Fixed with startup rehydration plus the role fallback. Lesson: any state that defends against attacks must be rebuildable after memory loss.
Fold the whole defense together and it's a closed loop of interlocking layers:
Token buckets → stop peak floods (millisecond-level)
Abuse scoring → keep the cumulative ledger (Lua-atomic, four windows, weighted)
Tiered penalties → translate scores into punishment (L1~L6, IP/user tiered)
Filter block → keep the banned out (403 + unblock time)
Restart recovery → bans survive reboots (DB rehydration + role fallback)
Each layer has an irreplaceable job: without rate limiting, peak requests hit the backend directly; without scoring, slow abuse is never caught; without tiers, one false positive is a permanent ban; without the Filter, a ban exists only "in the ledger"; without recovery, one restart resurrects the attacker. Remove any layer and the wall has a hole.
A design philosophy runs through the whole system worth naming: it punishes behavior, not identity. One over-limit is a reminder; repeated crossings throttle; sustained malice bans — every step leaves room to return to normal. Anti-abuse and user experience were never opposites — the trick is making legitimate users never feel it exists, while making every malicious step progressively more expensive.
If you're building a platform that can be abused, this "limit + score + escalate + block + recover" loop is directly copyable. Five things to remember: per-endpoint frequency budgets (no one-size-fits-all); weights by resource cost (compute > storage > query); tier IP and user separately (IP conservative, user strict); bans must persist (memory can be lost); leave doors for admins and auth paths (don't lock yourself out).
Anti-abuse isn't welding the door shut — it's letting good people through unimpeded, and making every step of the bad people cost more. With these five layers, your platform can survive the night against: credential stuffing, captcha bombing, upload bombing, AI quota draining, bot like-spamming — and in the morning, open the records table and see exactly who was banned and why, in black and white.
Every critical branch in this system's code carries a comment — "admins exempt, the first iron rule," "auth path whitelist, leave a door for users," "memory can be lost, databases cannot." Those comments aren't for the machine. They're for the person who inherits this in six months: anti-abuse is a long campaign, and you owe them a map of why each wall exists.