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.

Overview

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()).

flowchart TD A[Incoming request] --> B{Which filter} B -->|Auth API routes| C[AuthApiRateLimitFilter] B -->|JWT API routes| D[JwtApiRateLimitFilter] C --> E[RateLimiterService] D --> E E --> F{IP allowed} F -->|No| G[JSON error response] F -->|Yes| H{User checks} H -->|Blocked / throttled| G H -->|Pass| I[Controller runs] I --> J{Response status} J -->|4xx except 429/403/451| K[Record IP + user failures] J -->|Other| L[No extra recording]

Core service

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).

IP level

User level

Manual operations exposed on the service include blockIp, unblockIp, blockUser, and unblockUser, which clear the relevant cache keys for counters, violations, and block records.

Auth API filter

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).

JWT API filter

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.

Configuration

Defaults in app/Config/RateLimiter.php (adjust per environment as needed):

KeyMeaning (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.

How to change block count and duration

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.

What controls what

Duration conversion quick reference

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)

Sample 1: Strict production policy

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,
];

Sample 2: Balanced default-like policy

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,
];

Sample 3: Dev / QA friendly policy

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,
];

Change workflow (safe rollout)

  1. Copy current values from RateLimiter.php to your release notes for rollback.
  2. Change one policy group at a time (for example JWT first, then auth).
  3. Deploy and clear cache keys if required by your cache backend strategy.
  4. Monitor 429/403/451 counts and support tickets for 24-48 hours.
  5. Adjust violation_soft and durations gradually, not in large jumps.
!
Important: In this implementation, a duration of 0 is treated as long-lived and practically permanent until manual unblock via unblockIp() or unblockUser().

Manual unblock samples

Use unblockUser() and unblockIp() when support confirms a genuine user was blocked by policy. Keep unblock actions auditable (who unblocked, why, and when).

Sample: controller/admin action

<?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',
        ]);
    }
}

Sample: CLI / one-off script logic

$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).

Unblock SOP (short checklist)

  1. Verify requester: confirm account identity (email/mobile/user ID) from ticket context.
  2. Check scope: determine whether block is user-level, IP-level, or both.
  3. Apply least-risk fix: run unblockUser() first; use unblockIp() only if still blocked and justified.
  4. Audit it: record ticket ID, operator, timestamp, action taken, and reason.
  5. Watch rebound: monitor logs/metrics for quick re-block; escalate if abuse pattern continues.

Cache TTL and auto-release

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.

DB reconciliation (cron)

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:

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).

!
Config changes: expiry for reconciliation uses current 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.

HTTP responses

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().

Wiring routes

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.

Blocked list URL

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

Smoke test command

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).

Operational notes