Vortos
Authentication

Rate Limiting

Per-user, per-IP, and global request throttling with compile-time validation, Redis counters, headers, Problem Details responses, and observability.

Rate Limiting

Rate limits protect application availability. Use them for login protection, burst control, scraping prevention, expensive endpoints, and accidental client loops.

Use quotas for product allowances such as monthly exports or checkout orders.

Runtime Cost

Rate limit attributes are scanned at compile time. Runtime enforcement does not reflect controller attributes; it reads a prebuilt route map.

Concepts

ConceptMeaning
PolicyReturns a RateLimitRule for the current identity
Rulelimit plus windowSeconds
ScopeBucket isolation: user, IP, or global
CounterRedis key incremented once per checked request

Step-by-Step

Generate a policy:

php vortos make:rate-limit-policy Api -c Billing

Define limits:

src/Billing/Application/Policy/ApiRateLimitPolicy.php
namespace App\Billing\Application\Policy;

use Vortos\Auth\Contract\UserIdentityInterface;
use Vortos\Auth\RateLimit\Contract\RateLimitPolicyInterface;
use Vortos\Auth\RateLimit\RateLimitRule;

final class ApiRateLimitPolicy implements RateLimitPolicyInterface
{
    public function getLimit(UserIdentityInterface $identity): RateLimitRule
    {
        return match ($identity->getAttribute('plan', 'free')) {
            'enterprise' => RateLimitRule::unlimited(),
            'pro' => new RateLimitRule(limit: 1000, windowSeconds: 60),
            default => new RateLimitRule(limit: 100, windowSeconds: 60),
        };
    }
}

Apply the policy:

use App\Billing\Application\Policy\ApiRateLimitPolicy;
use Vortos\Auth\RateLimit\Attribute\RateLimit;

#[RateLimit(ApiRateLimitPolicy::class)]
final class CreateOrderController {}

Scopes

User Scope

Use for authenticated API usage.

use Vortos\Auth\RateLimit\RateLimitScope;

#[RateLimit(ApiRateLimitPolicy::class, per: RateLimitScope::User)]
final class CreateOrderController {}

Key shape:

rl:user:{userId}:{controller}:{policy}

IP Scope

Use for unauthenticated or abuse-prone endpoints.

#[RateLimit(LoginIpRateLimitPolicy::class, per: RateLimitScope::Ip)]
final class LoginController {}

Key shape:

rl:ip:{clientIp}:{controller}:{policy}

IP limits run before authentication, so login and registration endpoints are covered.

Global Scope

Use for protecting an endpoint as a whole.

#[RateLimit(GlobalCheckoutRateLimitPolicy::class, per: RateLimitScope::Global)]
final class CreateOrderController {}

Key shape:

rl:global:{controller}:{policy}

Stacking Scopes

All stacked rate limits must pass. The first exceeded rule returns 429.

#[RateLimit(LoginIpRateLimitPolicy::class, per: RateLimitScope::Ip)]
#[RateLimit(LoginGlobalRateLimitPolicy::class, per: RateLimitScope::Global)]
final class LoginController {}

For authenticated APIs:

#[RateLimit(ApiRateLimitPolicy::class, per: RateLimitScope::User)]
#[RateLimit(GlobalApiRateLimitPolicy::class, per: RateLimitScope::Global)]
final class ExportController {}

Middleware Order

priority 7: RateLimit IP + Global   before auth
priority 6: AuthMiddleware          resolves identity
priority 4: RateLimit User          after auth

This ordering avoids a common security bug: unauthenticated endpoints still get IP/global protection, while user limits get a real user ID.

Response Headers

Rate-limited responses include:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1717200000

Exceeded responses also include:

Retry-After: 47

Limit Exceeded Response

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 47
{
  "type": "https://docs.vortos.dev/errors/rate-limit-exceeded",
  "title": "Rate Limit Exceeded",
  "status": 429,
  "detail": "Too many requests. Please retry after 47 seconds.",
  "instance": "/api/v1/login",
  "extensions": {
    "policy": "App\\Billing\\Application\\Policy\\LoginIpRateLimitPolicy",
    "scope": "ip",
    "limit": 10,
    "remaining": 0,
    "reset_at": 1717200000,
    "retry_after": 47
  }
}

Configuration

config/auth.php
return static function (VortosAuthConfig $config): void {
    $config
        ->rateLimitHeaders(true)
        ->problemDetails(true);
};

Observability

Metrics:

rate_limit_allowed_total{policy,scope,controller}
rate_limit_blocked_total{policy,scope,controller}
http_blocked_total{reason,status}

Logs are emitted on blocked requests. Tracing records deny events when tracing is configured.

Rate-limited requests also mark the request as low-value telemetry. Vortos increments http_blocked_total{reason="rate_limit",status="429"} and skips expensive trace enrichment for the rejected request.

Success paths only increment metrics by default. This keeps high-throughput endpoints efficient under FrankenPHP worker mode.

Security Notes

  • Policies are validated at compile time.
  • Missing policies fail container compilation.
  • IP limits are not a substitute for authentication.
  • Never use high-cardinality labels such as user ID in metrics.
  • Do not return sensitive policy internals in custom response extensions.

Troubleshooting

Rate limit never triggers

Check Redis is configured. Rate limit middleware is registered only when Redis is available.

Then verify the controller is discovered as a controller service and the attribute references the correct policy class.

Per-user limits behave like anonymous limits

Use RateLimitScope::User, not Ip, and ensure the route is authenticated before user-scope enforcement.

Retry-After is zero

The Redis key may have expired between the increment and TTL read. This is rare and safe. Clients should retry normally when Retry-After is zero.

Users behind one NAT are blocked together

That is expected with IP scope. Use IP scope for abuse control and user scope for authenticated plan limits.

On this page