The API rate limiter combines a shared RateLimiterService with two route filters:
one for unauthenticated auth-style endpoints (email / mobile in the request body or query), and
one for JWT-protected APIs. Both enforce IP-level throttling and progressive blocks, and tie
additional counters to a resolved user identity when one is available.
A client fingerprint is built with generateFingerprint(exclude_ua: true), so the
IP-level bucket is keyed primarily by client IP (not the full user-agent string). The service
stores counters and block metadata in the application cache (Config\Services::cache()).
App\Libraries\RateLimiterService reads limits from app/Config/RateLimiter.php.
It separates IP behaviour (shared $config->ipBlock) from
user behaviour (shared $config->userBlock for block durations and
escalation), while per-route-type windows and limits use either $jwtApi or
$authApi depending on the string passed from the filter (jwtApi vs
authApi).
checkIp($fingerprint, $routeType) — if the IP is already blocked, returns a block payload and may escalate soft → medium → hard when additional requests hit while blocked.recordIpFailure($fingerprint) — increments the same violation path (used from
filter after() on failed controller responses).hash('sha256', strtolower(trim($identity))).checkUser($identity) — returns a block response if that identity is already blocked.checkUserThrottle($identity, 'jwtApi') — used on JWT routes: block check first, then
a per-user request counter in a time window (same violation → soft-block pattern as IP).recordUserFailure($identity, $routeType) — increments user violations when the
controller returns a failure; thresholds use the route-type config (authApi or
jwtApi).userBlock / ipBlock (zero
duration is treated as long-lived until manual unblock).
Manual operations exposed on the service include blockIp, unblockIp,
blockUser, and unblockUser, which clear the relevant cache keys for
counters, violations, and block records.
App\Filters\AuthApiRateLimitFilter targets routes that do not rely on
JWT (for example mobile verification or OTP flows). Identity is resolved from POST fields first,
then GET: email (lower-cased) or mobile_number (trimmed).
checkIp($fingerprint, 'authApi'), then if identity exists,
checkUser($identity) only (no per-user request throttle before the controller).recordIpFailure and recordUserFailure($identity, 'authApi') when identity
was stashed or can still be resolved — so failed logins or bad OTP attempts feed the violation
counters.Cors filter’s after()
handler so CORS headers stay consistent on early exits.
The class JwtApiRateLimitFilter lives in app/Filters/JwtApiFilter.php.
It resolves identity from getEmailFromJWT() or getMobileFromJWT() when those
helpers exist; invalid JWTs are caught and the request falls back to IP-only limiting.
checkIp($fingerprint, 'jwtApi'), then
checkUserThrottle($identity, 'jwtApi') when identity is known.recordUserFailure($identity, 'jwtApi') for the JWT identity when available.
Defaults in app/Config/RateLimiter.php (adjust per environment as needed):
| Key | Meaning (current defaults) |
|---|---|
jwtApi |
60 requests per 60 seconds per user identity; 3 violations before a soft user block. |
authApi |
10 requests per 180 seconds at the IP bucket for auth routes; 3 user violations (from failed responses) before a soft user block. |
ipBlock |
120 requests per 60 seconds per fingerprint; 5 violations before soft IP block; medium/hard durations and triggers for escalation while blocked. |
userBlock / ipBlock durations |
Soft can be stored as long TTL when duration is 0; medium 2 hours; hard 24 hours (see config). |
statusCodes |
Throttle and soft blocks use 429; medium 403; hard 451. |
All tuning happens in app/Config/RateLimiter.php. No filter code changes are needed
for normal policy updates. Edit values, deploy, and clear cache if your backend keeps old keys.
jwtApi.limit and jwtApi.window: per-user request throttle for JWT APIs.authApi.limit and authApi.window: auth-route request throttle window used by
IP checks in the auth filter flow.jwtApi.violation_soft / authApi.violation_soft: number of recorded violations
before applying a soft user block.ipBlock.limit and ipBlock.window: global per-fingerprint request throttle.ipBlock.violation_soft: over-limit events before soft IP block.userBlock.soft_duration, medium_duration, hard_duration:
user-block durations in seconds.ipBlock.soft_duration, medium_duration, hard_duration:
IP-block durations in seconds.userBlock.medium_trigger / hard_trigger and equivalent in
ipBlock: attempts while already blocked that escalate level.300 = 5 minutes
900 = 15 minutes
1800 = 30 minutes
3600 = 1 hour
7200 = 2 hours
86400 = 24 hours
0 = permanent-style block (manual unblock expected)
public array $jwtApi = [
'limit' => 45,
'window' => 60,
'violation_soft' => 2,
];
public array $authApi = [
'limit' => 8,
'window' => 180,
'violation_soft' => 2,
];
public array $userBlock = [
'soft_duration' => 1800, // 30 min
'medium_duration' => 7200, // 2 hours
'hard_duration' => 86400, // 24 hours
'medium_trigger' => 1,
'hard_trigger' => 1,
];
public array $ipBlock = [
'limit' => 100,
'window' => 60,
'violation_soft' => 4,
'soft_duration' => 1800,
'medium_duration' => 7200,
'hard_duration' => 86400,
'medium_trigger' => 1,
'hard_trigger' => 1,
];
public array $jwtApi = [
'limit' => 60,
'window' => 60,
'violation_soft' => 3,
];
public array $authApi = [
'limit' => 10,
'window' => 180,
'violation_soft' => 3,
];
public array $userBlock = [
'soft_duration' => 900, // 15 min
'medium_duration' => 3600, // 1 hour
'hard_duration' => 86400, // 24 hours
'medium_trigger' => 2,
'hard_trigger' => 2,
];
public array $jwtApi = [
'limit' => 200,
'window' => 60,
'violation_soft' => 20,
];
public array $authApi = [
'limit' => 40,
'window' => 180,
'violation_soft' => 10,
];
public array $userBlock = [
'soft_duration' => 60, // 1 min
'medium_duration' => 300, // 5 min
'hard_duration' => 900, // 15 min
'medium_trigger' => 5,
'hard_trigger' => 5,
];
public array $ipBlock = [
'limit' => 300,
'window' => 60,
'violation_soft' => 30,
'soft_duration' => 60,
'medium_duration' => 300,
'hard_duration' => 900,
'medium_trigger' => 5,
'hard_trigger' => 5,
];
RateLimiter.php to your release notes for rollback.violation_soft and durations gradually, not in large jumps.0 is treated as long-lived and practically
permanent until manual unblock via unblockIp() or unblockUser().
Use unblockUser() and unblockIp() when support confirms a genuine user was
blocked by policy. Keep unblock actions auditable (who unblocked, why, and when).
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Libraries\RateLimiterService;
class SecurityController extends BaseController
{
public function unblockRateLimitedUser(): \CodeIgniter\HTTP\ResponseInterface
{
$identity = trim((string) $this->request->getPost('identity'));
if ($identity === '') {
return $this->response->setStatusCode(422)->setJSON([
'success' => false,
'message' => 'identity is required',
]);
}
$limiter = new RateLimiterService();
$limiter->unblockUser($identity);
return $this->response->setJSON([
'success' => true,
'identity' => $identity,
'message' => 'User rate-limit state cleared',
]);
}
}
$limiter = new \App\Libraries\RateLimiterService();
$identity = 'user@example.com';
$fingerprint = 'known-fingerprint-key';
$limiter->unblockUser($identity);
$limiter->unblockIp($fingerprint);
If your support team only has an email/mobile, unblock user first. IP unblock should be done more carefully because multiple users may share an IP (office NAT, VPN, mobile carrier).
unblockUser() first; use unblockIp() only if still blocked and justified.
Runtime enforcement of a block is whether the block payload exists in the application cache
(RateLimiterService::blockIp() / blockUser() call
$this->cache->save(..., $ttl)). When $ttl is a positive number of seconds
(medium and hard levels in app/Config/RateLimiter.php), the entry expires after that
period. The next cache->get() no longer returns block data, so the client is no longer
blocked for API checks (throttle and violation keys use their own TTLs).
With the default file cache handler (app/Config/Cache.php),
CodeIgniter’s FileHandler treats an item as expired when
now > stored_time + ttl; on read it removes the file and returns empty, so behaviour
matches a timed release without a separate unlock job.
When a level’s configured duration is 0 (for example soft blocks in the stock config),
the service stores a very long TTL (effectively manual unblock). Those rows are not “timed” blocks
in the operational sense.
Active blocks are also upserted into the rate_limit_blocks table for admin visibility
(/security/rate-limits). Cache entries for medium/hard can disappear on TTL while the
database row stays status = active until something cleans it up. A scheduled job keeps
the index aligned with real enforcement and clears any leftover cache keys.
Spark command (implementation: app/Commands/RateLimitBlocksReconcile.php):
php spark rate-limit:reconcile-blocks --dry-run
php spark rate-limit:reconcile-blocks
Behaviour summary:
status = 'active'.blocked_at + duration(block_level) using the same duration fields
as RateLimiter (userBlock vs ipBlock depending on
block_type). Rows whose duration is <= 0 are skipped so permanent-style
soft blocks are not removed by the job.RateLimiterService::purgeIpBlockCaches() or
purgeUserBlockCaches() (same cache keys as manual unblock, without updating the DB
row first), then deletes the row from rate_limit_blocks.--dry-run prints what would be reconciled without changing cache or the database.Example cron (every 10 minutes on Linux):
*/10 * * * * cd /path/to/nhance && php spark rate-limit:reconcile-blocks >> /path/to/logs/rate-limit-reconcile.log 2>&1
On Windows, use Task Scheduler with the same command, set “Start in” to the project directory, and a
10-minute trigger. Deleting reconciled rows removes them from the admin list; if you need a full audit
trail instead, consider changing the job to mark unblocked rather than delete (not the
current implementation).
RateLimiter values, not the numbers that were in effect when the block was created.
If you shorten durations in config, old rows can become eligible sooner than the original policy.
When the service returns a structured result, filters respond with JSON of the form:
{
"success": false,
"error": {
"code": "RATE_LIMIT_THROTTLE",
"message": "Too many requests. Please slow down.",
"type": "ip"
}
}
For progressive blocks, code uses RATE_LIMIT_ plus the level
(SOFT, MEDIUM, HARD), and type is
ip or user. Messages for blocks are defined in
RateLimiterService::blockedResponse().
Aliases in app/Config/Filters.php:
'AuthApiRateLimitFilter' => AuthApiRateLimitFilter::class,
'JwtApiRateLimitFilter' => JwtApiRateLimitFilter::class,
Attach them per route (or route group) with the filter option, for example:
$routes->post('api/auth/verify-otp', 'AuthController::verifyOtp', ['filter' => 'AuthApiRateLimitFilter']);
$routes->get('api/profile', 'ProfileController::index', ['filter' => 'JwtApiRateLimitFilter']);
Ensure JWT helpers used by JwtApiRateLimitFilter match your authentication stack; the
filter comments note replacing helper names if your project uses different entry points.
Admin can view active blocked IP and blocked user entries at:
/security/rate-limits
This page has two tabs (IP and User), shows block level, and provides unblock action per row.
It is protected by ACL and restricted to ADMIN_ROLE_ID only.
Direct actions on the same feature:
POST /security/rate-limits/unblock-ip
POST /security/rate-limits/unblock-user
The project includes a targeted smoke test for this service at
tests/unit/RateLimiterServiceSmokeTest.php. Run it with:
php vendor/bin/phpunit --filter RateLimiterServiceSmokeTest
Expected result on success: OK (3 tests, 77 assertions) (assertion count can change as
tests evolve).
0 second duration, stored as a long TTL) still require
unblockIp / unblockUser or admin unblock.rate_limit_blocks rows removed after timed blocks end, and stray cache files cleared.