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
| Concept | Meaning |
|---|---|
| Policy | Returns a RateLimitRule for the current identity |
| Rule | limit plus windowSeconds |
| Scope | Bucket isolation: user, IP, or global |
| Counter | Redis key incremented once per checked request |
Step-by-Step
Generate a policy:
php vortos make:rate-limit-policy Api -c BillingDefine limits:
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 authThis 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: 1717200000Exceeded responses also include:
Retry-After: 47Limit 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
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.