Categoría / Laravel
Rate limiting en una API Laravel: límites por usuario, IP y respuestas 429
Protege rutas de una API Laravel con límites combinados por usuario e IP, respuestas JSON 429 y pruebas reproducibles.
Un límite de frecuencia no sustituye a la autenticación, autorización ni validación: reduce el abuso y da al cliente una respuesta predecible cuando debe esperar. En esta guía protegeremos las escrituras de una API con dos presupuestos: uno por usuario autenticado y otro por IP.
Si aún no tienes rutas, recursos JSON y Policies, empieza por crear una API REST segura con Laravel. La elección de credenciales es independiente del límite: una API con JWT en Laravel o con tokens de Sanctum puede usar la misma política. Para una SPA propia, usa el flujo de Laravel Sanctum con Vue en vez de emitir tokens personales por costumbre.
Decide qué protege cada clave
Un único límite por IP es útil para peticiones anónimas, pero puede penalizar a usuarios distintos detrás de la misma red. Uno solo por usuario permite que una misma cuenta genere demasiada carga desde varias IP. En una ruta autenticada, combina ambos:
| Clave | Objetivo | Ejemplo de umbral |
|---|---|---|
| Usuario | Evitar que una cuenta automatice operaciones costosas | 20 escrituras por minuto |
| IP | Reducir picos desde un origen, incluso con varias cuentas | 60 escrituras por minuto |
Los números no son universales. Empieza por el coste real de la operación y por el comportamiento esperado del cliente; mide rechazos antes de endurecerlos. No uses Limit::none() para cuentas VIP salvo que tengas otra protección de capacidad.
Define un limitador para las escrituras
Registra el limitador en boot() de app/Providers/AppServiceProvider.php. Laravel evalúa todos los límites que devuelve el array. Prefijar las claves evita que dos presupuestos distintos compartan el mismo contador.
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
$tooManyRequests = function (Request $request, array $headers): JsonResponse {
return response()->json([
'message' => 'Has superado temporalmente el límite de peticiones.',
'code' => 'rate_limit_exceeded',
], 429, $headers);
};
RateLimiter::for('api-write', function (Request $request) use ($tooManyRequests) {
$user = $request->user();
return [
Limit::perMinute(20)
->by('user:'.$user->getAuthIdentifier())
->response($tooManyRequests),
Limit::perMinute(60)
->by('ip:'.$request->ip())
->response($tooManyRequests),
];
});
}
}
Este limitador está pensado para rutas autenticadas: el middleware de la siguiente sección garantiza que $request->user() exista antes de evaluarlo. Para una ruta pública define otro limitador cuya clave sea la IP u otra señal apropiada, en lugar de reutilizar api-write.
by() define quién comparte contador; no es una autorización. Los prefijos también evitan colisiones accidentales entre claves de distinto tipo. En producción, comprueba que Request::ip() refleja al cliente real si tu aplicación está detrás de un proxy de confianza; si no, todos los clientes podrían compartir la IP del proxy.
Laravel responde automáticamente con HTTP 429 cuando se agota un límite. La llamada a response() solo normaliza el cuerpo JSON; conserva el array $headers que proporciona Laravel para que el cliente pueda respetar la espera indicada por la respuesta.
Aplica el límite después de autenticar
Asigna el middleware nombrado a las rutas que modifican estado. Colocar auth:sanctum antes de throttle:api-write permite crear la clave de usuario cuando la petición lleva un token de Sanctum. Si usas JWT, usa el middleware de tu guard en su lugar; el limitador sigue obteniendo el usuario autenticado desde la petición.
use App\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth:sanctum', 'throttle:api-write'])->group(function (): void {
Route::post('/tasks', [TaskController::class, 'store']);
Route::patch('/tasks/{task}', [TaskController::class, 'update']);
Route::delete('/tasks/{task}', [TaskController::class, 'destroy']);
});
No apliques este límite por defecto a todas las lecturas sin analizar su coste. Un endpoint de búsqueda puede necesitar una política distinta, y un login debe combinar IP e identidad introducida para evitar que el atacante rote direcciones o cuentas.
Trata 429 como parte del contrato de la API
El cliente no debe reintentar inmediatamente una respuesta 429: produciría más rechazos. Puede mostrar un estado temporal y volver a intentar cuando corresponda, siempre con un límite de reintentos y sin duplicar una escritura.
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Preparar informe' }),
});
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
throw new Error(
retryAfter === null
? 'Demasiadas peticiones. Inténtalo de nuevo más tarde.'
: `Demasiadas peticiones. Vuelve a intentarlo en ${retryAfter} segundos.`,
);
}
if (!response.ok) {
throw new Error('No se pudo guardar la tarea.');
}
Un 429 no confirma si la operación se ejecutó o no en un cliente que perdió la conexión antes de recibir la respuesta. Para escrituras que no pueden duplicarse, diseña además una clave de idempotencia o un identificador de operación en el contrato de tu API.
Prueba el presupuesto y la respuesta
Un test debe consumir exactamente el presupuesto y comprobar que la siguiente petición devuelve 429. Aísla la ruta de prueba y el usuario para no compartir contadores con otros casos de la suite.
use App\Models\User;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Sanctum\Sanctum;
it('limita las escrituras de una cuenta', function () {
$user = User::factory()->create();
Sanctum::actingAs($user, ['*']);
$this->withServerVariables([
'REMOTE_ADDR' => '198.51.100.42',
]);
for ($attempt = 0; $attempt < 20; $attempt++) {
$this->postJson('/api/tasks', ['name' => "Tarea {$attempt}"])
->assertCreated();
}
$this->postJson('/api/tasks', ['name' => 'Tarea bloqueada'])
->assertTooManyRequests()
->assertHeader('Retry-After')
->assertJson(fn (AssertableJson $json) => $json
->where('code', 'rate_limit_exceeded')
->etc(),
);
});
Añade un segundo caso con dos usuarios y la misma IP si el límite por IP es relevante para tu producto. Comprueba también que una petición sin autenticar devuelve 401 antes de alcanzar el limitador. Decide de forma explícita si las respuestas 403 deben consumir presupuesto: el limitador mostrado cuenta cualquier petición autenticada que llegue a la ruta, aunque la autorización posterior la rechace.
Checklist antes de activarlo
- Define límites distintos para operaciones de coste distinto; no copies 60 por minuto a todas las rutas.
- Usa un almacén de caché compartido entre instancias cuando tu API escala horizontalmente; de lo contrario cada instancia puede tener un contador diferente.
- Conserva las cabeceras del 429 y documenta al cliente cómo esperar antes de reintentar.
- Observa cuántos 429 se producen por ruta y clave, sin registrar tokens, cuerpos sensibles ni identificadores personales innecesarios.
- Mantén autenticación, Policies y validación de Form Requests: un cliente bajo el límite todavía puede enviar una petición no permitida.