Crear una API REST escalable, modular y protegida en Laravel 13

- Andrés Cruz - EN In english

Video thumbnail

Índice de contenido

Una Rest Api no es más que una interfaz entre sistemas que usa HTTP para obtener y enviar datos o generar operaciones sobre esos datos en varios formatos como XML y JSON.

Para crear una Rest Api, podemos emplear exactamente la misma lógica que manejamos hasta ahora; la única diferencia es donde van a estar registradas nuestras rutas, que ya no estarían en el archivo de web.php si no en el archivo de api.php.

Para este capítulo, vamos a crear un nuevo proyecto en Laravel aunque, puedes emplear el mismo proyecto que hemos empleado hasta ahora, si decides crear un nuevo proyecto, debes de copiar las migraciones, request y modelos de Post y Categoría.

Las API RESTs brindan una forma flexible y liviana de integrar aplicaciones; es decir, en la cual podemos comunicar dos o más aplicaciones.

Veamos los conceptos claves:

  • Una API es un conjunto de reglas que definen cómo las aplicaciones o dispositivos pueden conectarse y comunicarse entre sí.

  • Una API REST no es más que una API que se ajusta a los principios de diseño de REST y utiliza las peticiones de HTTP (GET, POST, PUT, PATCH, DELETE) para realizar el consumo y gestión de estos datos.

    • La arquitectura REST no es más que un conjunto de restricciones o limitaciones entre las principales, tenemos:

      • Separación entre el cliente y el servidor; es decir, dos aplicaciones aparte.
      • Sin estado, es decir, por buenas prácticas, no debemos usar sesiones o mecanismos similares.
      • Cacheable, para hacer más eficiente, podemos guardar en caché la respuesta a un mismo recurso.
      • Una interfaz uniforme tanto para el consumo de la misma en la cual cada recurso debe de tener una URI establecida y respuestas devueltas en JSON o XML.

En la práctica, una Rest Api no es más que una aplicación o módulo de la misma, la cual cuenta con un conjunto de funciones implementadas que pueden ser consumidas mediante una URL y las mismas pueden hacer operaciones CRUD para administrar los datos. La Rest Api es consumida mediante peticiones HTTP y siempre devuelven un mismo tipo de dato; JSON principalmente.

Ya conocemos como realizar operaciones CRUD en Laravel con los modelos en Base de Datos, ahora, vamos a aprovechar estos conocimientos para crear una Rest Api.

¿Qué es una API REST?

Te voy a contar una pequeña “historia de abuelo”.

Supongamos que tenemos nuestra súper aplicación en Laravel. Ya tenemos nuestras entidades, podemos crear registros, editarlos, eliminarlos.

Ahora bien, imagina que queremos consumir esa información desde otra aplicación, por ejemplo, una aplicación hecha en Vue, React, Angular, Astro, o cualquiera de los 20 frameworks de JavaScript que existen.

Pero no solo eso. También podría ser una aplicación móvil:

  • Android (Android Studio)
  • React Native
  • Flutter (mi favorita)
  • iOS con Swift o SwiftUI

Piensa, por ejemplo, en Gmail: tú puedes ver tus correos desde el navegador, pero también desde el móvil. Lo que está pasando ahí es que la aplicación móvil (el cliente) se conecta a un servidor, y ese servidor expone una API.

Eso es básicamente una API REST: Un mecanismo que permite que distintas aplicaciones se comuniquen entre sí.

Servidor y cliente

En la mayoría de los casos, tenemos:

  • Un servidor, que en nuestro caso será Django.
  • Un cliente, que puede ser una aplicación web o una app móvil.

Aunque normalmente conectamos servidor con cliente, también podrías consumir una API REST desde otra aplicación en Django, o desde Laravel, Flask, etc. En resumen: una API REST nos permite interconectar aplicaciones, sin importar la tecnología que usen.

Qué puede hacer una API REST

Una API REST no sirve solo para crear, leer, actualizar o eliminar datos. También puede:

  • Enviar correos
  • Ejecutar procesos
  • Automatizar tareas
  • Exponer servicios a terceros

Todo es programable.

La idea clave que quiero que te quedes es esta:

Una API REST es un mecanismo para conectar aplicaciones distintas, normalmente un servidor con uno o varios clientes.

REST y sus reglas

Una API REST no es solo “devolver datos”. Tiene reglas.

Por ejemplo:

  • GET → consultar datos
  • POST → crear registros
  • PUT / PATCH → actualizar (total o parcialmente)
  • DELETE → eliminar

Aunque técnicamente podrías usar GET para crear datos o POST para consultar, no es lo recomendado, por temas de seguridad y buenas prácticas.

REST es, básicamente, un conjunto de normas que nos dicen cómo debemos hacer esa comunicación.

HTTP y los métodos

Aquí hay algo importante:

HTML solo entiende GET y POST, pero el protocolo HTTP (el que usamos cuando navegamos por internet, con HTTP o HTTPS) soporta muchos más métodos:

  • GET
  • POST
  • PUT
  • PATCH
  • DELETE

Y precisamente las APIs REST se basan en HTTP, no en HTML.

APIs en general

Una API es un interfaz de programación de aplicaciones. Existen muchos tipos de APIs:

  • SOAP
  • GraphQL
  • REST

Todas sirven para comunicar aplicaciones, pero cada una tiene sus propias reglas, ventajas y desventajas, igual que pasa cuando comparas Django con Laravel.

Códigos de Estado (HTTP Status Codes)

En una API, ya no devolvemos una página de error visual, sino un código numérico que la aplicación cliente (Vue, Flutter, etc.) debe interpretar:

  • 200 OK: Todo salió bien.
  • 201 Created: El registro se creó con éxito.
  • 400 / 422: Error del cliente (datos mal enviados o validación fallida).
  • 401 Unauthorized: El usuario no ha enviado un token válido.
  • 404 Not Found: El recurso no existe.
  • 500 Internal Server Error: Nuestro servidor Laravel explotó por un error de lógica.

¿Qué es un JSON exactamente?

Si nunca has visto uno a fondo, es simplemente un formato de texto basado en pares clave-valor.

  • Si es un solo objeto, usamos llaves {}.
  • Si es una lista de datos (como el index), usamos corchetes [] para representar un array.

Ejemplo de lo que devuelve tu nueva ruta:

[
 {
   "id": 1,
   "title": "Laravel 11",
   "slug": "laravel-11"
 },
 {
   "id": 2,
   "title": "Vue.js",
   "slug": "vue-js"
 }
]

Instalación de la API

A partir de Laravel 11, el archivo de api.php no se encuentra publicado, para publicarlo, debemos de ejecutar los comandos de artisan:

$ php artisan install:api

El archivo api.php contiene las rutas para la creación de una Api Rest; estas rutas están diseñadas para no tener estado, por lo que las solicitudes que ingresan a la aplicación a través de estas rutas deben autenticarse mediante tokens y no tendrán acceso al estado de la sesión.

Con esto, se publicará el archivo de api.php:

  • routes\api.php

Y se instalará Sanctum en el proceso que es un paquete para habilitar la autenticación que trataremos más adelante:

Installing dependencies from lock file (including require-dev)

Package operations: 1 install, 0 updates, 0 removals
  - Downloading laravel/sanctum (vX.X)
  - Installing laravel/sanctum (vX.X): Extracting archive

Ya con esto, para acceder a las rutas, debemos de colocar la URL del dominio seguido del prefijo de api:

<DOMAIN>/api/<RESOURCE>

Por ejemplo:

http://larafirststeps.test/api/category

Si quieres personalizar las rutas para indicar otro prefijo que no sea el de API:

use Illuminate\Support\Facades\Route;
 
->withRouting(
    web: __DIR__.'/../routes/web.php',
    commands: __DIR__.'/../routes/console.php',
    health: '/up',
    then: function () {
        Route::middleware('api')
            ->prefix('webhooks')
            ->name('webhooks.')
            ->group(base_path('routes/webhooks.php'));
    },
)

Más información en:

https://laravel.com/docs/master/routing#routing-customization

Recuerda ejecutar las migraciones en caso de que existan:

$ php artisan migrate

Creando el API Controller

En una REST API dejamos de devolver HTML (vistas Blade) para devolver JSON, que es el formato estándar, ligero y fácil de leer para las máquinas. Para mantener el orden, guardaremos estos controladores en una carpeta específica:

app/Http/Controllers/Api.

Controladores y rutas

Creamos los controladores para las APIs:

$ php artisan make:controller Api/PostController -m Post

Y

$ php artisan make:controller Api/CategoryController -m Category

Creamos las rutas en:

routes/api.php:

Route::resource('category', App\Http\Controllers\Api\CategoryController::class)->except(["create", "edit"]);
Route::resource('post', App\Http\Controllers\Api\PostController::class)->except(["create", "edit"]);

Para evitar conflictos entre los nombre de las rutas en el dashboard y las de API, vamos a colocarle un prefijo a los nombre de las rutas de la API:

routes/api.php:

Route::group(['as' => 'api.'], function () {
    Route::resource('category', CategoryController::class)->only(['index']);
    Route::resource('post', PostController::class)->only(['index']);
});

Los controladores lucen como:

Api/CategoryController.php

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Category\PutRequest;
use App\Http\Requests\Category\StoreRequest;
use App\Models\Category;
use Illuminate\Http\JsonResponse;
class CategoryController extends Controller
{
    public function index(): JsonResponse
    {
        return response()->json(Category::paginate(10));
    }
    public function store(StoreRequest $request): JsonResponse
    {
        return response()->json(Category::create($request->validated()));
    }
    public function update(PutRequest $request, Category $category): JsonResponse
    {
        $category->update($request->validated());
        return response()->json($category);
    }
    public function destroy(Category $category): JsonResponse
    {
        $category->delete();
        return response()->json(['message' => 'Deleted'], 204);
    }
}

Y para el de Post:

Api/PostController.php

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Post\PutRequest;
use App\Http\Requests\Post\StoreRequest;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
class PostController extends Controller
{
    public function index(): JsonResponse
    {
        return response()->json(Post::paginate(10));
    }
    public function store(StoreRequest $request): JsonResponse
    {
        return response()->json(Post::create($request->validated()));
    }
    public function show(Post $post): JsonResponse
    {
        return response()->json($post);
    }
    public function update(PutRequest $request, Post $post): JsonResponse
    {
        $post->update($request->validated());
        return response()->json($post);
    }
    public function destroy(Post $post): JsonResponse
    {
        $post->delete();
        return response()->json(['message' => 'Deleted'], 204);
    }
}

Explicación del código anterior

Puedes ver que prescindimos de algunos métodos como el de los formularios de edit y create; ya que, en una Api Rest, no es necesario estas vistas intermedias para crear los recursos, recordemos que, estas son empleadas para pintar el formulario y nada más, y en una Rest Api, esto no sería necesario, solamente mantener los procesos de crear y editar.

Finalmente, siempre devolvemos una respuesta en formato JSON con: response()->json().

La cual recibe dos parámetros:

  1. Los datos.
  2. El código de estado.

Para exponer la categoría con toda la información y no solo el identificador:

{
   "id": 1,
   "title": "Post 1",
   "slug": "post-1",
   "description": "test",
   "content": "test",
   "image": "test",
   "posted": "yes",
   "category_id": 1,
   "created_at": null,
   "updated_at": null,
   "category": {
     "id": 1,
      "title": "cate 1 new",
      "slug": "cate-1"
    }
}

Podemos indicar que traiga la relación al momento de hacer la paginación:

Api/PostController.php

public function index()
{
    return response()->json(Post::with('category')->paginate(10));
}

Aunque response()->json() devuelve por defecto un estado 200 (OK), las buenas prácticas sugieren ser más específicos:

  • 201 (Created): Ideal para el método store, indicando que el recurso se creó con éxito.
  • 204 (No Content): Es el estándar para el método destroy. Significa que la operación fue exitosa pero no hay contenido que mostrar (porque el registro ya no existe).

Te recomiendo familiarizarte con los códigos de estado HTTP.

Manejar excepciones

Para manejar las excepciones, específicamente aquellas que ocurren cuando no existen los registros al momento de la búsqueda, por ejemplo:

"message": "No query results for model [App\\Models\\Category] cate-1asas",
    "exception": "Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException",

Si cambias la variable APP_DEBUG=false en tu archivo .env, Laravel dejará de mostrar esos detalles técnicos y mostrará una página de error genérica. Sin embargo, para una API, queremos un control más fino.

A partir de Laravel 11, tenemos el manejo de las configuraciones globales en un solo archivo:

bootstrap\app.php

Así que, desde el método de:

withExceptions

Manejamos las excepciones, desde el mencionado método podemos capturar las excepciones que queremos personalizar:

bootstrap\app.php

return Application::configure(basePath: dirname(__DIR__))
    ***
    ->withMiddleware(function (Middleware $middleware) {
        //
    })
    ->withExceptions(function (Exceptions $exceptions) {
        $exceptions->render(function (NotFoundHttpException $e, $request) {
            if($request->expectsJson()){ // or $request->wantsJson()
                return response()->json('Not found',404);
            }
        });
    })->create();

Especificamos un manejo de excepción específico para la excepción que está ocurriendo que es la de:

Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException

Y si se espera recibir una respuesta JSON ($this->expectsJson()) el cual es el formato que vamos a emplear desde la Api Rest; y en este caso, generamos una excepción personalizada como la que implementamos anteriormente. Desde el archivo anterior, puedes personalizar el comportamiento de cualquier otra excepción que consideres.

¿Cómo funciona este flujo?

  1. Captura: El método render detecta específicamente la excepción que definas (puedes hacer Control+Click en la clase para ver todas las que Laravel ofrece en la carpeta Vendor).
  2. Discriminación: Usamos $request->expectsJson(). Esto es vital porque si el usuario está navegando en el Dashboard (web) y algo no se encuentra, queremos que vea la página 404 de Blade, no un código JSON.
  3. Respuesta: Si es una petición de API (gracias al header Accept: application/json que configuramos en Postman), devolvemos nuestra respuesta personalizada.

Implementar métodos personalizados

En este apartado, vamos a crear algunos métodos específicos para el consumo de los posts o categorías.

Obtenerlas todas

Ahora, vamos a crear un par de métodos para obtener todos los registros sin paginación:

app\Http\Controllers\Api\PostController.php

public function all(): JsonResponse
{
    return response()->json(Post::get());
}

Y

app\Http\Controllers\Api\CategoryController.php

public function all(): JsonResponse
{
    return response()->json(Category::get());
}

Las rutas:

routes\api.php

Route::get('post/all', [PostController::class, 'all']);
Route::get('category/all', [CategoryController::class, 'all']);

Consumir por el slug

Para consumir por el slug, podemos usar directamente el de show, pero, variando el parámetro en la URL, que NO sea el ID que es el por defecto si no el campo de slug:

Y para las URL, algo como las siguientes:

routes\api.php

Route::get('post/slug/{post:slug}', [App\Http\Controllers\Api\PostController::class, 'show']);
Route::get('category/slug/{category:slug}', [App\Http\Controllers\Api\CategoryController::class, 'show']);

Puedes variar la URL, pero es importante que no cause conflicto con otra ya existente, por ejemplo, la de show.

Si consumimos el método anterior, tendremos algo como lo siguiente:

// http://larafirststeps.test/api/post/slug/xgyxsfyabgyefiaubhog

{
  "id": 1,
  "title": "xGYxsFYABgyEFiAuBhOg",
  "slug": "xgyxsfyabgyefiaubhog",
  "description": "Lorem ipsum dolor sit amet consectetur, adipisicing elit. Vitae ",
  "content": "<p>Lorem ipsum dolor sit amet consectetur, adipisicing elit. Vitae aperiam culpa veritatis quasi laudantium mollitia quidem est blanditiis ullam illum cupiditate suscipit, quia, itaque quaerat? Iure debitis laudantium aliquam maxime!</p>",
  "image": null,
  "posted": "yes",
  "created_at": "2026-03-14T18:20:14.000000Z",
  "updated_at": "2026-03-14T18:20:14.000000Z",
  "category_id": 11,
  "category": {
    "id": 11,
    "title": "Categoria 10",
    "slug": "categoria-10",
    "created_at": "2026-03-14T18:20:14.000000Z",
    "updated_at": "2026-03-14T18:20:14.000000Z"
  }
}

Si quieres que traiga la categoría asociada, puedes usar el esquema de:

$post = Post::with("category")->where("slug", $slug)->firstOrFail();

O

$post = Post::where("slug", $slug)->firstOrFail();
$post->category;

Importante notar el segundo caso, Laravel trabaja con un esquema lazy loading, lo que significa, es que, no va a traer los datos de relaciones al menos que los solicites; en el segundo caso, estamos consumiendo la categoría del post seleccionado y por ende, realiza la consulta a la base de datos y queda registrado en el objeto de post.

El método firstOrFail() trae un único registro según la condición (al igual que el método de firts()), si no lo encuentra, entonces da un error 404.

Otra variación para el caso anterior, es definir el método de la siguiente manera:

public function slug(Post $post): JsonResponse // $slug
  {
     //$post = Post::with("category")->where("slug", $slug)->firstOrFail();
     $post->category;
     return response()->json($post);
}

Importante notar que, ahora tenemos el post inyectado en la método (es decir, como parámetro, a esto se le conoce como inyección de dependencia) por lo tanto, para indicar a Laravel que lo que va a recibir es el slug y que haga el mapeo al post; esto, lo indicamos por las rutas:

Route::get('post/slug/{post:slug}', [PostController::class, 'slug']);

Para las categorías, vamos a realizar el mismo procedimiento:

public function slug(Category $category): JsonResponse
{
   return response()->json($category);
}

Y la ruta:

Route::get('category/slug/{category:slug}', [CategoryController::class, 'slug']);

Código fuente:

https://github.com/libredesarrollo/book-course-laravel-base-api-11/releases/tag/v0.1

¿Cuándo UNIFICAR Endpoints en tu REST API? (Optimización Real)

Video thumbnail

En este apartado, quiero hablarte sobre como cuidar tu Rest Api, algunas diretrices que debes de tener en cuenta para que tu RestAPI NO se salga de control y sea mantenible unificando endpoints o que tomes estas recomendaciones para cuando quieras crear un nuevo endpoint.

Una de las optimizaciones fundamentales al desarrollar aplicaciones que consumen una REST API es la gestión eficiente de las consultas HTTP. A medida que una plataforma evoluciona y suma nuevas funcionalidades, existe la tendencia de crear endpoints independientes para cada módulo. Sin embargo, para mantener una arquitectura limpia y de alto rendimiento, es necesario analizar la aplicación como un todo unificado.

Identificación del Problema: Múltiples Peticiones Redundantes

Un escenario habitual en aplicaciones web es la dispersión de peticiones durante la carga inicial. Por ejemplo, al cargar una interfaz de usuario, es común encontrar peticiones independientes para:

  • Obtener el perfil del usuario (suscripción, datos de cuenta, token).
  • Consultar el listado o contador de notificaciones no leídas.
  • Obtener el estado actual del carrito de compras.

Aunque cada componente UI maneje su lógica con variables locales o gestores de estado, realizar múltiples llamadas HTTP consecutivas para datos que se requieren en el mismo ciclo de vida genera una sobrecarga innecesaria tanto en el servidor como en el cliente.

Regra de Oro para la Unificación de Endpoints

Como principio de arquitectura de software, se debe aplicar la siguiente regla: Si un conjunto de datos siempre viaja junto en el mismo ciclo de inicialización de la página, debe unificarse en una sola petición HTTP.

Refactorizar la API para que un único endpoint devuelva la información del usuario junto a sus notificaciones y el carrito de compras reduce la latencia, consolida las llamadas y simplifica la inicialización del estado en el cliente.

Estrategias de Gestión de Estado y Carga Inicial

El problema de unificar endpoints, es que, desde la app, seguramente se consumen estos endpoints desde distintos módulos de la app, por ejemplo, las notificaciones o  el carrito de compras son MODULOS apartes pero estamos trayendo los datos unificados en un SOLO endpoints que le tenemos que compartir estos datos que antes resolvían por ellos mismos en un endpoints exclusivo.

Existen diversas alternativas para disponibilizar información básica antes o durante la ejecución de las peticiones de la API:

  • Inyección en la ventana global (Window Object): Compartir un objeto inicial renderizado por el backend desde el servidor permite una lectura inmediata para componentes visuales de solo lectura.
  • Gestores de Estado (Pinia / Vuex): Al recibir la respuesta del endpoint unificado, los datos se distribuyen en el almacén global para que componentes independientes (como el encabezado o la barra lateral) puedan consumirlos sin realizar peticiones adicionales.
  • Persistencia Local y Cookies: Utilizadas para almacenar identificadores de sesión o configuraciones rápidas, aunque con un acceso de lectura más lento en comparación con la memoria del estado global.

Consideraciones para Control de Versiones y Clientes Móviles

Al refactorizar y consolidar endpoints en la REST API, es crucial evaluar el impacto en otros clientes del ecosistema, como aplicaciones móviles creadas en Flutter o React Native.

Si se decide eliminar o modificar un endpoint antiguo, no debe removerse de inmediato en producción. Los usuarios de aplicaciones móviles no siempre actualizan la aplicación instantáneamente; eliminar un endpoint de forma abrupta provocará errores de red (404) y fallos en las versiones instaladas. La estrategia correcta exige mantener el endpoint deprecated durante un tiempo prudencial antes de su eliminación definitiva.

Estrategias de Protección de una API REST en Laravel con Firma Digital HMAC y Timestamps

Video thumbnail

Una vez implementada una REST API, el siguiente paso fundamental es garantizar su seguridad. Salvo que esté concebida como un servicio público, la gran mayoría de las APIs deben estar restringidas para ser consumidas de forma exclusiva por nuestras propias aplicaciones, evitando accesos no autorizados, consumo indebido de recursos o extracción masiva de datos.

Protección en Entornos Web Mediante CORS

En aplicaciones web tradicionales, el mecanismo primario y obligatorio de protección se basa en la configuración de políticas CORS (Cross-Origin Resource Sharing). Este método define explícitamente cuáles dominios tienen autorización previa para realizar peticiones hacia la API.

Si una petición proviene de un origen no incluido en la lista blanca del servidor, la solicitud es rechazada automáticamente con un código de estado HTTP 403 Forbidden. No obstante, aunque CORS funciona de manera estricta en navegadores web, no ofrece protección frente a clientes que no respetan estas políticas, como las aplicaciones móviles o las herramientas de pruebas HTTP.

En el caso de Laravel, el archivo es config/cors.php

Protección para Aplicaciones Móviles: HMAC y Timestamps

A diferencia del entorno web, las aplicaciones móviles (desarrolladas en Flutter, Kotlin, Swift o frameworks similares) no operan bajo el concepto de un dominio de origen. Para validar que las solicitudes provienen únicamente de una aplicación móvil legítima y que los datos no han sido alterados en tránsito, se debe implementar una firma digital con HMAC (Hash-based Message Authentication Code) combinada con un Timestamp.

Este estándar de seguridad se fundamenta en tres pilares principales:

  • Clave Secreta Compartida (Secret Key): Una cadena confidencial almacenada únicamente dentro del cliente móvil y en el backend, la cual nunca viaja explícitamente a través de la red.
  • Carga Útil (Payload) y URI: Los datos de la petición (como el endpoint solicitado y sus parámetros) que se utilizan para calcular la firma. Esto evita ataques de manipulación de datos, impidiendo que un atacante altere identificadores dentro de la petición.
  • Timestamp y Tolerancia de Caducidad: Se incluye el tiempo actual (en milisegundos Unix) en la firma. El servidor valida que la petición haya sido generada dentro de un rango de tolerancia específico (por ejemplo, 30 a 300 segundos), bloqueando posibles ataques de denegación o retransmisión (replay attacks).

Para garantizar una comunicación íntegra, auténtica e inalterable, la estándar en la industria es la implementación de firmas digitales HMAC-SHA256 combinadas con marcas de tiempo (timestamps). En esta guía completa aprenderás la teoría detrás de este patrón de seguridad y cómo implementarlo paso a paso con un middleware personalizado en Laravel.

Es una firma digital dinámica (HMAC). En lugar de enviar una "contraseña" estática por la red que cualquiera pueda interceptar y copiar en Postman, lo que haces es firmar el sobre de la petición en tiempo real.

HMAC (Hash-based Message Authentication Code) es un mecanismo de autenticación de mensajes que combina una función hash criptográfica (como SHA-256) con una clave secreta compartida.

Su objetivo principal es garantizar dos cosas en una transmisión de datos:

  1. Autenticidad: Confirmar que el mensaje proviene de quien dice ser (solo quien conoce la clave secreta pudo haberlo generado).
  2. Integridad: Asegurar que los datos no han sido alterados o manipulados en el camino.

1. Fundamentos Teóricos: HMAC, Integridad y Replay Attacks

El esquema de firma de peticiones HTTP repose en la técnica de secreto compartido (Shared Secret). La aplicación cliente (Flutter, Vue, React, etc.) y el servidor backend poseen una clave secreta que jamás viaja a través de la red en texto plano.

Este sistema proporciona tres capas de defensa fundamentales:

  • Integridad de los datos: Garantiza que la ruta o el contenido no hayan sido alterados durante el tránsito.
  • Autenticidad del origen: Confirma que la solicitud proviene de un cliente legítimo que posee la clave secreta.
  • Protección contra Replay Attacks: El timestamp fija la caducidad de la petición, impidiendo que solicitudes capturadas sean reejecutadas minutos o días después.

El flujo de firma y verificación

  1. En el Cliente:
    • Genera la estampa de tiempo actual en milisegundos (timestamp).
    • Construye la cadena base o payload (ejemplo: $timestamp . $formattedPath).
    • Calcula el hash encriptado usando HMAC-SHA256 con la clave secreta.
    • Adjunta la firma y el timestamp en las cabeceras HTTP (X-Signature y X-Timestamp).
  2. En el Servidor (Middleware):
    • Intercepta la petición y extrae las cabeceras.
    • Valida que la diferencia de tiempo entre el servidor y el timestamp recibido esté dentro de un margen aceptable (ej. 300 segundos).
    • Normaliza la ruta entrante y reconstruye exactamente el mismo payload.
    • Calcula la firma HMAC localmente y la compara de forma segura contra la firma recibida.

En resumen:

Tanto Laravel como Flutter conocen en secreto una misma palabra clave (llamada $secret). Cuando la app de Flutter va a hacer una petición, realiza la siguiente fórmula matemática rápida:

Firma = HMAC-SHA256}(Fecha} + Ruta + Datos, $secret)

  1. La app calcula la firma en ese instante y la mete en la cabecera (X-Signature).
  2. Envía el paquete: La petición viaja con la fecha, la ruta y la firma, pero nunca se transmite el $secret.
  3. Laravel recibe el paquete: Hace el mismo cálculo en su servidor usando los datos que le llegaron.
    1. Si la firma que calculó Laravel coincide con la que envió Flutter → La petición viene de tu app legítima.
    2. Si cambia un solo carácter o la fecha difiere por más de 30 segundos → Devuelve 403 Forbidden.

Flujo del Algoritmo de Autenticación HMAC

  1. En el cliente móvil (ej. Flutter):
    • Se obtiene el Timestamp universal actual.
    • Se construye la cadena base (Payload) uniendo el Path del endpoint, el Timestamp y cualquier parámetro adicional.
    • Se genera el Hash firmado aplicando un algoritmo criptográfico (como SHA-256) sobre la cadena base junto a la clave secreta.
    • Se envían la firma calculada y el Timestamp dentro de las cabeceras HTTP (Headers) de la solicitud.
  2. En el servidor backend (ej. Middleware en Laravel):
    • El Middleware intercepta la petición entrante y extrae las cabeceras HTTP correspondientes.
    • Se verifica que el Timestamp se encuentre dentro del margen de tolerancia permitido.
    • Se reconstruye el Payload utilizando el Path de la petición recibida y la clave secreta almacenada en la configuración del servidor.
    • Se calcula el Hash localmente y se compara con la firma recibida. Si coinciden, la petición continúa su flujo; de lo contrario, se retorna un error de autenticación 401 Unauthorized.

2. Creación del Middleware de Validación en Laravel

Implementaremos la validación en un middleware personalizado que procesará las solicitudes antes de que alcancen los controladores.

Paso 1: Crear la clase del Middleware

Ejecuta el siguiente comando en tu terminal para generar la estructura:

php artisan make:middleware ValidateApiSignature

Paso 2: Código de la Clase Middleware

Abre el archivo app/Http/Middleware/ValidateApiSignature.php e implementa la siguiente lógica:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class CheckAppSignature
{
    public function handle(Request $request, Closure $next): Response
    {
        // 1. Obtener el origen de la petición (lo envía el navegador desde Vue/Axios)
        $origin = $request->header('Origin') ?? $request->header('Referer');

        // Cargar dinámicamente el array 'allowed_origins' definido en config/cors.php
        $allowedOrigins = config('cors.allowed_origins', []);

        // dd('/' . ltrim($request->path(), '/'));

        // 2. Si la petición viene de un origen Web permitido, bypass a la firma HMAC
        if ($origin) {
            foreach ($allowedOrigins as $allowed) {
                if (str_starts_with($origin, $allowed)) {
                    return $next($request);
                }
            }
        }

        // 3. Si NO viene de la Web (ej. App Móvil Flutter), se EXIGE la firma HMAC
        $timestamp = $request->header('X-Timestamp');
        $signature = $request->header('X-Signature');
        $secret = config('app.mobile_app_secret');

        if (!$timestamp || !$signature) {
            return response()->json([
                'message' => 'Acceso no autorizado: Petición fuera del ecosistema.'
            ], 403);
        }

        // Prevenir Replay Attacks (30 segundos de vigencia)
        if (abs(time() - (int) $timestamp) > 30) {
            return response()->json([
                'message' => 'Petición expirada.'
            ], 403);
        }

        // Reconstruir Payload y verificar Firma
        $path = '/' . ltrim($request->path(), '/');
        $payload = $timestamp . $path;
        $expectedSignature = hash_hmac('sha256', $payload, $secret);

        if (!hash_equals($expectedSignature, $signature)) {
            return response()->json([
                'message' => 'Firma de aplicación inválida.'
            ], 403);
        }

        return $next($request);
    }
}

3. Configuración y Registro del Middleware

Agrega la clave secreta compartida dentro de tu archivo de variables de entorno .env:

API_SECRET_KEY=tu_clave_secreta_super_segura_32_caracteres

Luego, edita config/app.php para mapear la variable:

'api_secret_key' => env('API_SECRET_KEY'),

Registrar el Middleware en Laravel 11 / 12

En las estructuras modernas de Laravel, añade el alias de tu middleware dentro del archivo bootstrap/app.php:

use App\Http\Middleware\ValidateApiSignature;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->alias([
            'signed.api' => ValidateApiSignature::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

4. Protección de Rutas en routes/api.php

Para aplicar la protección, simplemente asigna el middleware signed.api a tus endpoints. Puedes combinarlo con Sanctum para exigir tanto un usuario autenticado como una firma válida:

use App\Http\Controllers\Api\V1\VideoController;
use Illuminate\Support\Facades\Route;

Route::middleware(['auth:sanctum', 'signed.api'])->group(function () {
    Route::get('/v1/tutorial/video/protect/vimeo/{id}', [VideoController::class, 'getVimeoStreamUrl']);
});

5. Implementación en el Cliente (Flutter / Dart)

Para firmar digitalmente las peticiones desde nuestra app móvil en Flutter, utilizaremos el paquete oficial de criptografía de Dart.

Paso 1: Instalación de dependencias

Añade el paquete crypto ejecutando el siguiente comando en la raíz de tu proyecto Flutter:

flutter pub add crypto

Paso 2: Generación de la Firma HMAC en Dart

A continuación, se muestra una clase auxiliar o método en Flutter para construir el payload, generar el hash HMAC-SHA256 y realizar la petición enviando las cabeceras requeridas:

import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;

class ApiClient {
  static const String _secretKey = 'tu_clave_secreta_super_segura_32_caracteres';
  static const String _baseUrl = 'www.desarrollolibre.net';

  /// Genera la firma digital HMAC-SHA256
  static String generateSignature(String payload) {
    final keyBytes = utf8.encode(_secretKey);
    final payloadBytes = utf8.encode(payload);
    final hmac = Hmac(sha256, keyBytes);
    final digest = hmac.convert(payloadBytes);
    return digest.toString();
  }

  /// Ejemplo de petición GET protegida
  static Future<http.Response> getProtectedVideo(String videoId, String userToken) async {
    final String relativePath = 'api/v1/tutorial/video/protect/vimeo/$videoId';

    // 1. Obtener timestamp en milisegundos
    final String timestamp = DateTime.now().millisecondsSinceEpoch.toString();

    // 2. Normalizar la ruta (asegurar '/' al inicio y eliminar '/' al final)
    final String formattedPath = (relativePath.startsWith('/') ? relativePath : '/$relativePath')
        .replaceAll(RegExp(r'/$'), '');

    // 3. Crear el payload a firmar
    final String payload = '$timestamp$formattedPath';

    // 4. Calcular la firma HMAC
    final String signature = generateSignature(payload);

    // 5. Construir la URI y realizar la solicitud con las cabeceras de seguridad
    final Uri url = Uri.https(_baseUrl, relativePath);

    return await http.get(
      url,
      headers: {
        'Authorization': 'Bearer $userToken',
        'X-Timestamp': timestamp,
        'X-Signature': signature,
        'Accept': 'application/json',
      },
    );
  }
}

6. Buenas Prácticas en el Desarrollo del Cliente Móvil

Para mantener la mantenibilidad del código cuando la aplicación crece y realiza decenas de peticiones a la API, la lógica de generación de cabeceras HMAC no debe repetirse manualmente en cada llamada HTTP. Se deben aplicar principios de Orientación a Objetos mediante la creación de un cliente HTTP centralizado o wrapper:

  • Centralización del Cliente HTTP: Implementar una clase base encarga de gestionar los métodos estándar (GET, POST, PUT, DELETE).
  • Inyección Automática de Cabeceras: Un único método privado dentro de la clase base debe calcular el Timestamp, procesar la firma HMAC e inyectar las cabeceras requeridas antes de despachar cualquier solicitud.
  • Variables de Entorno Cifradas: Guardar la clave secreta en archivos de configuración protegidos o variables de entorno del cliente para evitar su exposición directa en el código fuente.

7. Buenas Prácticas de Seguridad Adicionales

  • Uso estricto de HTTPS: Cifra la capa de transporte para evitar que un sniffer lea las cabeceras HTTP o los tokens de sesión.
  • Uso obligatorio de hash_equals(): Evita el uso de operadores condicionales estándar (== o ===). La función hash_equals() compara cadenas en tiempo constante, mitigando vulnerabilidades de Timing Attacks.
  • Rotación de claves: Mantén un esquema para actualizar la clave secreta del servidor de forma periódica.

Conclusión

La combinación de CORS para la capa web y HMAC con Timestamps para clientes móviles proporciona un esquema de seguridad robusto, modular y escalable. Esta arquitectura protege los endpoints contra la suplantación de origen, la manipulación de parámetros en tránsito y los ataques de retransmisión, garantizando la integridad total de la REST API.

Implementar firmas HMAC combinadas con marcas de tiempo en Laravel crea un esquema robusto de defensa en profundidad. Con esta solución, cualquier petición modificada o fuera de tiempo será descartada automáticamente por el middleware, garantizando la máxima seguridad para los servicios REST de tu plataforma.

Video thumbnail

Estrategias de Protección de una API REST en Laravel con Fingerprinting en los JWT Tokens

Anteriormente exploramos una arquitectura para proteger una REST API y garantizar que solo pueda ser consumida por nuestros clientes autorizados. Hicimos un breve repaso sobre el uso de firmas HMAC y marcas de tiempo (timestamps): dos componentes esenciales donde el primero es un hash de verificación y el segundo define una ventana de vigencia para la petición. Si un tercero intercepta el token e intenta ejecutar una operación fuera de ese lapso, la petición es rechazada por caducidad. Esta estrategia resulta indispensable al salir del entorno web tradicional, donde no disponemos de la protección por CORS predeterminada. El proceso consiste en generar un hash con una clave secreta compartida entre cliente y servidor, añadiendo la fecha actual para garantizar un valor único y difícil de descifrar.

Sin embargo, queda un aspecto crítico por resolver: la autenticación del usuario mediante JSON Web Tokens (JWT). Si un atacante obtiene el token de un usuario legítimo, puede producirse una suplantación de identidad o el uso del mismo token en múltiples dispositivos no autorizados. Para mitigar este riesgo, implementaremos una estrategia basada en Fingerprinting (huellas digitales de dispositivo).

En arquitecturas modernas basadas en microservicios, SPAs y aplicaciones móviles, el mecanismo de autenticación predominante son los tokens Bearer. Ya sea mediante JSON Web Tokens (JWT) o tokens de acceso personal gestionados por bases de datos relacionales como Laravel Sanctum, estos tokens presentan una vulnerabilidad intrínseca de diseño: son credenciales al portador. Quien posee la cadena de texto del token adquiere la identidad del usuario sin importar desde qué dispositivo, red o navegador se ejecute la solicitud.

Si un token es extraído de las cookies del navegador, interceptado a través de ataques de Hombre en el Medio (MitM), capturado por malware en el cliente o filtrado accidentalmente en repositorios y registros, un atacante puede reutilizarlo libremente desde Postman, cURL o un script malicioso. En este artículo abordamos la implementación de un mecanismo de Client Fingerprint (Huella Digital del Cliente) generado exclusivamente a partir de las cabeceras estándar del protocolo HTTP, integrándolo de forma nativa con Laravel Sanctum y consumiéndolo con interceptores globales en Vue 3 (Axios) y Flutter (HTTP Client).

Arquitectura de la solución: Huella Digital basada en el Request HTTP

Para evitar exigir cabeceras personalizadas adicionales al cliente móvil o web (las cuales pueden ser falsificadas fácilmente si el atacante conoce el esquema), el sistema extrae las características del entorno directamente de las cabeceras nativas del objeto $request de Laravel:

  • User-Agent: La firma de la plataforma o motor de renderizado (difiere entre el motor Dart/Flutter, navegadores Chromium, Safari, Firefox y clientes de prueba).
  • Accept: Los tipos de contenido MIME que el cliente está dispuesto a recibir (application/json, text/html, etc.).
  • Accept-Language: La configuración regional y lista de idiomas preferidos del sistema operativo o navegador.
  • Subred IP: Los primeros tres octetos de la dirección IPv4 (o los primeros cuatro bloques en IPv6). Esta decisión de diseño tolera pequeñas fluctuaciones dentro de una misma red local o router doméstico sin invalidar la sesión del usuario legítimo ante reinicios de conexión DHCP.

Identificación Única del Dispositivo

Revisemos la lógica encargada de obtener y generar el token de usuario: si el token ya existe en la sesión o petición, simplemente se retorna; si el usuario se está autenticando, se genera uno nuevo. El cambio fundamental en esta función es la inclusión obligatoria del objeto Request, el cual proporciona el contexto necesario para la validación.

La implementación incorpora un nuevo servicio encargado de identificar las características del dispositivo. De esta forma, el token emitido queda estrictamente vinculado a ese entorno mediante una firma o fingerprint.

Para evitar modificar el esquema de la base de datos añadiendo una columna dedicada a la huella digital, podemos aprovechar las estructuras existentes. Por ejemplo, en Laravel Sanctum es posible utilizar el campo de habilidades (abilities) del PersonalAccessToken para almacenar este hash. No obstante, esta arquitectura es agnóstica al framework y puede replicarse en Django, FastAPI, Express o cualquier otro entorno backend.

Al momento de crear el token, la habilidad se registra bajo la clave fingerprint junto con su codificación hash correspondiente. En caso de que la aplicación ya utilice el sistema de habilidades para control de acceso, la alternativa recomendada es agregar una columna dedicada en la tabla de tokens.

Implementación del servicio generador en Laravel: ClientFingerprintService

El servicio de generación procesa las propiedades del Request para construir una identidad digital única del cliente. Entre las métricas utilizadas se encuentran:

  • El encabezado de lenguajes aceptados (Accept-Language).
  • El agente de usuario (User-Agent).
  • El tipo de contenido aceptado (Accept).
  • La dirección IP del cliente.

Nota sobre direcciones IP: En aplicaciones móviles, la IP del cliente puede cambiar con frecuencia según la red (Wi-Fi o datos móviles). Es conveniente evaluar si la IP debe incluirse en la generación del hash o si debe normalizarse mediante métodos auxiliares.

Al igual que con HMAC, el resultado final es un hash que vincula el token al contexto específico de la petición. Si un atacante intercepta el token, no podrá replicar las solicitudes con éxito al desconocer la combinación exacta de parámetros con la que se firmó.

<?php
namespace App\Services;
use Illuminate\Http\Request;
class ClientFingerprintService
{
    /**
     * Genera un hash criptográfico SHA-256 basado en atributos del cliente.
     */
    public static function generate(Request $request): string
    {
        // 1. Firma exacta del cliente o navegador
        $userAgent = $request->header('User-Agent', 'unknown');
        // 2. Cabeceras de contenido y localización
        $accept = $request->header('Accept', '');
        $acceptLanguage = $request->header('Accept-Language', '');
        // 3. Subred IP para tolerar cambios menores en la misma conexión
        $ip = $request->ip() ?? '127.0.0.1';
        $ipSubnet = self::extractSubnet($ip);
        // Concatenación determinística
        $rawFingerprint = "{$ipSubnet}|{$accept}|{$acceptLanguage}|DL|{$userAgent}";
        return hash('sha256', $rawFingerprint);
    }
    private static function extractSubnet(string $ip): string
    {
        if (str_contains($ip, ':')) {
            // IPv6: primeros 4 bloques
            $parts = explode(':', $ip);
            return implode(':', array_slice($parts, 0, 4));
        }
        // IPv4: primeros 3 octetos (ej: 192.168.1)
        $parts = explode('.', $ip);
        return implode('.', array_slice($parts, 0, 3));
    }
}

Integración nativa con Laravel Sanctum sin alterar esquemas de base de datos

Para almacenar y validar esta huella sin necesidad de crear migraciones adicionales ni añadir columnas a la tabla personal_access_tokens, se aprovecha la funcionalidad nativa de habilidades (abilities) de Sanctum. Al emitir el token durante el flujo de autenticación, se inyecta la habilidad con el prefijo fingerprint:{hash}.

Modificación del generador central de tokens (Helper de Laravel)

Al procesar los endpoints de autenticación (login tradicional, login con código de un solo uso o autenticación social OAuth), la emisión del token captura el $request y asocia la huella calculada:

use App\Services\ClientFingerprintService;
function getUserTokenAuth(string $token = 'myapptoken', ?User $user = null, ?Request $request = null): string
{
    if ($user === null) {
        $user = auth()->user() ?? auth('sanctum')->user();
    }
    if ($user !== null) {
        $request = $request ?? request();
        $userAgent = $request->header('User-Agent') ?? $request->userAgent() ?? 'Unknown Device';
        // Identificación descriptiva del dispositivo
        $deviceName = 'Dispositivo Cliente';
        if (str_contains($userAgent, 'Dart')) {
            $deviceName = 'App Móvil (Flutter)';
        } elseif (str_contains($userAgent, 'Chrome')) {
            $deviceName = 'Navegador Chrome';
        }
        // Generación de huella digital única
        $fingerprint = ClientFingerprintService::generate($request);
        // Creación del token de Sanctum vinculando la huella en las abilities
        return $user->createToken($deviceName, ["fingerprint:{$fingerprint}"])->plainTextToken;
    }
    return '';
}

Middleware de verificación: VerifyClientFingerprint

Para procesar las peticiones entrantes se implementa un Middleware especializado en la verificación de la huella digital. El flujo de validación opera bajo las siguientes pautas:

  1. Rutas públicas: Si la petición no requiere autenticación y no incluye un token, el middleware permite el paso directo.
  2. Extracción de habilidad: Si existe un token, se busca la propiedad fingerprint dentro de sus habilidades registradas.
  3. Comparación en memoria: Se regenera el hash con los datos del Request actual y se compara con el almacenado en el token. Si no coinciden, se deniega el acceso inmediatamente.

Es indispensable optimizar este proceso para evitar sobrecargar la base de datos con consultas adicionales por cada petición. Al momento de autenticar al usuario, los datos del token ya se encuentran cargados en memoria, por lo que la validación del hash se ejecuta a nivel de software sin impacto en la capa de persistencia.

El middleware actúa como guardián perimetral en la capa HTTP. Debe resolver dos escenarios fundamentales:

  1. Rutas públicas o sin autenticación: Si la petición no incluye cabecera Authorization: Bearer, la solicitud sigue su flujo normal sin bloqueo para permitir la navegación anónima en catálogos y recursos abiertos.
  2. Rutas con credenciales de token: Localiza el registro del token en la base de datos de Sanctum, extrae la huella registrada en sus abilities y la compara contra el hash recalculado en tiempo real utilizando la función segura hash_equals (protección contra ataques de canal lateral o timing attacks).
<?php
namespace App\Http\Middleware;
use App\Services\ClientFingerprintService;
use Closure;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
use Symfony\Component\HttpFoundation\Response;
class VerifyClientFingerprint
{
    public function handle(Request $request, Closure $next): Response
    {
        $bearerToken = $request->bearerToken();
        // 1. Si no hay token en la cabecera, permitir el paso (rutas públicas)
        if (! $bearerToken) {
            return $next($request);
        }
        try {
            // 2. Obtener el token de Sanctum
            /** @var PersonalAccessToken|null $token */
            $token = $request->user('sanctum')?->currentAccessToken() ?? PersonalAccessToken::findToken($bearerToken);
            if (! $token) {
                return $next($request);
            }
            // 3. Extraer la huella registrada
            $expectedFingerprint = null;
            $abilities = $token->abilities ?? [];
            foreach ($abilities as $ability) {
                if (str_starts_with($ability, 'fingerprint:')) {
                    $expectedFingerprint = substr($ability, strlen('fingerprint:'));
                    break;
                }
            }
            // 4. Validación de coincidencia de entorno
            if ($expectedFingerprint !== null) {
                $currentFingerprint = ClientFingerprintService::generate($request);
                if (! hash_equals($expectedFingerprint, $currentFingerprint)) {
                    return response()->json([
                        'error' => 'Acceso denegado: El token fue emitido para otro cliente o entorno.',
                    ], 403);
                }
            }
        } catch (\Throwable $e) {
            return $next($request);
        }
        return $next($request);
    }
}

Este middleware se registra en bootstrap/app.php mediante un alias y se añade al grupo de rutas en routes/api.php junto a los mecanismos de firma HMAC de la API.

Cliente Web: Centralización de Axios e Interceptores en Vue 3

En el frontend desarrollado con Vue 3 y Pinia, todas las solicitudes HTTP deben homogeneizarse. Al fallar la validación por discrepancia de huella o expiración (códigos de estado 401 y 403), se deben purgar las credenciales locales de sesión (cookies y stores) y redirigir al usuario al formulario de inicio de sesión evitando ciclos de redirección infinita.

Configuración en main.js

import axios from 'axios'
import VueCookies from 'vue-cookies'
import { createToaster } from '@meforma/vue-toaster'
import { API_URLS } from './config/api'
import { useAuthStore } from './store/auth'
// Cabeceras HTTP predeterminadas
axios.defaults.headers.common['Accept'] = 'application/json'
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
axios.defaults.withCredentials = true
const toaster = createToaster({ position: 'bottom-right', duration: 3500 })
// Interceptor de respuestas de Axios
axios.interceptors.response.use(
    (response) => response,
    (error) => {
        if (error.response) {
            const status = error.response.status
            const url = error.config?.url || ''
            // 1. Manejo de sesiones expiradas o entornos inválidos
            if (status === 401 || status === 403) {
                VueCookies.remove('windowLaravel')
                VueCookies.remove('token')
                delete axios.defaults.headers.common['Authorization']
                try {
                    const authStore = useAuthStore(pinia)
                    authStore.destroyTokenAuthCookie()
                } catch (e) {}
                const isLoginRequest = url.includes(API_URLS.login) || url.includes('/login')
                const isAlreadyOnLogin = window.location.pathname.endsWith('/login')
                if (!isLoginRequest && !isAlreadyOnLogin) {
                    if (status === 403) {
                        const customMsg = error.response.data?.error || 'Acceso no autorizado o entorno no coincidente.'
                        toaster.error(customMsg)
                    }
                    setTimeout(() => {
                        window.location.href = '/login?expired=1'
                    }, 1200)
                }
            }
            // 2. Errores de validación y negocio (400)
            if (status === 400) {
                const msg = error.response.data?.message || error.response.data?.error
                if (msg && typeof msg === 'string') {
                    toaster.warning(msg)
                }
            }
            // 3. Fallos internos del servidor (500+)
            if (status >= 500) {
                toaster.error('Ha ocurrido un error en el servidor. Por favor, intenta de nuevo.')
            }
        }
        return Promise.reject(error)
    }
)

Cliente Móvil: Interceptor Centralizado y Alertas en Flutter (Dart)

En aplicaciones móviles Flutter, donde las llamadas HTTP suelen realizarse mediante paquetes como http, no existe un pipeline de middleware nativo como en Axios. Para resolver esto de manera uniforme, se implementa una clase estática Api que encapsula los verbos HTTP (GET, POST, PUT, DELETE), inyecta las cabeceras estándar (incluyendo Accept: application/json y X-Requested-With: XMLHttpRequest) y procesa todas las respuestas a través de un controlador centralizado.

Implementación de la clase Api y manejo de UI con NavigatorKey global

Para notificar visualmente al usuario sin necesidad de propagar instancias de BuildContext a través de toda la capa de datos y repositorios, se utiliza un GlobalKey<NavigatorState> configurado en el MaterialApp raíz.

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:desarrollolibre/utils/user_preference.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
class Api {
  static Future<http.Response> get(String path, {String? token, Map<String, dynamic>? data}) async {
    final url = Uri.https(baseUrlAPI, "$pathApi$path", data);
    final response = await http.get(url, headers: getApiHeaders(token: token, isJson: false, path: path));
    return _handleResponse(response, path);
  }
  static Future<http.Response> post(String path, {dynamic body, String? token, bool isJson = true}) async {
    final url = Uri.https(baseUrlAPI, "$pathApi$path");
    final response = await http.post(
      url,
      headers: getApiHeaders(token: token, isJson: isJson, path: path),
      body: isJson ? jsonEncode(body) : body,
    );
    return _handleResponse(response, path);
  }
  static http.Response _handleResponse(http.Response response, String path) {
    final status = response.statusCode;
    // Manejo de revocación de credenciales (401 y 403)
    if (status == 401 || status == 403) {
      final UserPreference userPreference = UserPreference();
      userPreference.token = '';
      userPreference.email = '';
      final bool isLoginEndpoint = path.contains('login') || path.contains('user/login');
      if (!isLoginEndpoint) {
        String errorMsg = 'Tu sesión ha caducado o el entorno no coincide.';
        try {
          final data = jsonDecode(response.body);
          if (data is Map && (data['error'] != null || data['message'] != null)) {
            errorMsg = data['error'] ?? data['message'];
          }
        } catch (_) {}
        _showAlert(
          title: status == 403 ? 'Acceso denegado' : 'Sesión expirada',
          message: errorMsg,
          icon: Icons.lock_outline_rounded,
          iconColor: Colors.redAccent,
        );
      }
    }
    if (status == 400) {
      _showAlert(
        title: 'Atención',
        message: 'Petición inválida o error en los datos enviados.',
        icon: Icons.warning_amber_rounded,
        iconColor: Colors.amber,
      );
    }
    if (status >= 500) {
      _showAlert(
        title: 'Error de servidor',
        message: 'Ha ocurrido un problema técnico. Intenta nuevamente más tarde.',
        icon: Icons.error_outline_rounded,
        iconColor: Colors.red,
      );
    }
    return response;
  }
  static void _showAlert({
    required String title,
    required String message,
    required IconData icon,
    required Color iconColor,
  }) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      final context = navigatorKey.currentContext;
      if (context == null) return;
      showDialog(
        context: context,
        builder: (ctx) => AlertDialog(
          shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
          title: Row(
            children: [
              Icon(icon, color: iconColor, size: 28),
              const SizedBox(width: 10),
              Expanded(child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold))),
            ],
          ),
          content: Text(message),
          actions: [
            TextButton(
              onPressed: () => Navigator.of(ctx, rootNavigator: true).pop(),
              child: const Text('Entendido', style: TextStyle(fontWeight: FontWeight.bold)),
            ),
          ],
        ),
      );
    });
  }
}

En resumen:

Para gestionar las respuestas denegadas (códigos HTTP 401 o 403) sin duplicar código en cada llamada a la API, la lógica debe centralizarse en los clientes de la aplicación:

  • Aplicaciones Web (Axios): Se configura un interceptor global de respuestas. Cuando la API retorna un código 403 o 401, el interceptor captura la respuesta, limpia el almacenamiento local (tokens, sesión) y redirige al usuario al flujo de inicio de sesión.
  • Aplicaciones Móviles (Flutter / HTTP): Se definen métodos centralizados para las peticiones HTTP (GET, POST, PUT, DELETE). Si la respuesta contiene un estado de no autorizado, un manejador global invoca el método de cierre de sesión y reajusta el estado de la aplicación.

Pruebas automatizadas y validación con Pest PHP

Para garantizar que el sistema responde fielmente a las políticas de seguridad y previene regresiones, se desarrollaron pruebas de integración con Pest PHP cubriendo la matriz completa de casos de uso:

  1. Determinismo criptográfico: Dos peticiones con idénticos headers y dentro de la misma subred IP producen exactamente el mismo hash SHA-256. Un cambio en el User-Agent o en el segmento de red altera el hash de salida.
  2. Tráfico no autenticado: Los endpoints abiertos responden exitosamente con código HTTP 200 cuando no se incluye cabecera de autorización.
  3. Consumo legítimo: Una petición con Bearer token emitido bajo el mismo entorno supera el middleware y permite la ejecución de la lógica protegida (HTTP 200).
  4. Intento de reutilización en entorno no coincidente: Un token emitido para Flutter utilizado desde un navegador web o cliente desktop es interceptado y rechazado inmediatamente con código HTTP 403 Forbidden y el mensaje: "Acceso denegado: El token fue emitido para otro cliente o entorno."
    1. Puedes probar hacer un CURL con el Token a un recurso protegido:
    2. curl -X GET "http://***" \
             -H "Authorization: Bearer ID|token" \
             -H "Accept: application/json"
  5. Tokens corruptos o inexistentes: Tokens con firmas inválidas son rechazados con código HTTP 401 Unauthorized.

Conclusión

La combinación de Client Fingerprinting en Laravel Sanctum con interceptores reactivos en el frontend (Vue 3) y cliente móvil (Flutter) eleva notablemente la postura de seguridad de una API REST. Al condicionar la validez del token al contexto HTTP de origen sin requerir identificadores de hardware invasivos ni alterar la estructura de la base de datos, se neutraliza la reutilización de tokens filtrados y se ofrece una experiencia de usuario clara ante caducidades o revocaciones de sesión.

Rate Limiting y resumen de estrategias de Protección y Optimización para API REST

Video thumbnail

Anteriormente hemos analizado diversas estrategias para proteger y optimizar una API REST mediante buenas prácticas de arquitectura, independientemente de la tecnología backend que utilices. Entre estas medidas destacan la unificación de recursos cuando son requeridos en la misma consulta, el uso de firmas digitales (HMAC) para aplicaciones móviles (como Flutter) fuera del entorno web tradicional, y el uso de huellas digitales (fingerprints) asociadas a tokens JSON Web Token (JWT) para evitar que sean clonados entre distintos navegadores o dispositivos.

Otra estrategia fundamental es la limitación de peticiones o Rate Limiting, un mecanismo que permite controlar la frecuencia de solicitudes entrantes para evitar el uso indebido y proteger la disponibilidad de la aplicación.

¿Qué es el Rate Limiting?

El Rate Limiting es una estrategia de control de tráfico que restringe la cantidad de solicitudes HTTP que un usuario o cliente puede realizar a un recurso de la API dentro de un periodo determinado (por ejemplo, 60 peticiones por minuto). Su objetivo es prevenir abusos, reducir el impacto de ataques de fuerza bruta y mitigar el desgaste inútil de recursos en el servidor.

Implementación de Rate Limiting en Laravel

En Laravel, los limitadores de tráfico se configuran en el proveedor de servicios de rutas (AppServiceProvider o RouteServiceProvider) utilizando la fachada RateLimiter. A continuación, se presenta la definición de un limitador general basado en la dirección IP del cliente:

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('general', function (Request $request) {
    return Limit::perMinute(150)->by($request->ip());
});

Análisis del Límite de Peticiones

Para determinar el número adecuado de peticiones permitidas por minuto (por ejemplo, 150 peticiones/minuto por IP), es necesario analizar la dinámica de navegación de la aplicación:

  • Llamadas múltiples por pantalla: Al cargar un módulo en la interfaz del cliente, la aplicación suele realizar varias peticiones concurrentes a la API REST (por ejemplo, para obtener los datos del usuario, las notificaciones, las categorías y el contenido principal). Esto refuerza la importancia de la unificación de recursos descrita en la primera estrategia.
  • Cómputo en navegación fluida: Un usuario navegando de forma activa puede generar un promedio de 2 peticiones por segundo, lo que equivale a 120 peticiones por minuto. Establecer un margen de 150 peticiones por minuto permite una experiencia fluida para el usuario legítimo, al tiempo que bloquea scripts automáticos que realicen decenas de peticiones por segundo.
  • Identificación por IP vs. Usuario Autenticado: Si la limitación se aplica exclusivamente por dirección IP, múltiples usuarios navegando bajo la misma red compartida (NAT) dividirán el cupo asignado. Para evitar este conflicto en entornos corporativos o institucionales, se puede alternar el criterio de restricción combinando el identificador del usuario autenticado y la dirección IP.

Protección Específica por Tipo de Recurso

No todos los endpoints requieren la misma cuota de consumo. Los recursos críticos o propensos a ataques de fuerza bruta deben contar con limitadores independientes más estrictos.

// Limitador restrictivo para endpoints de autenticación o acciones sensibles
RateLimiter::for('autenticacion', function (Request $request) {
    return Limit::perMinute(30)->by($request->ip());
});

Establecer un máximo de 30 peticiones por minuto en rutas de inicio de sesión o pagos evita que un atacante intente vulnerar credenciales por fuerza bruta o sature el procesador ejecutando algoritmos de derivación de claves (como Bcrypt o Argon2).

Resumen Estrategias

Modularización de Rutas para la Aplicación de Middleware

Mantener una arquitectura modular facilita la asignación de políticas de protección específicas a grupos de rutas. Una estructura organizada mediante archivos dedicados permite aplicar los limitadores de tráfico de forma centralizada:

// Rutas generales protegidas con el limitador "general" (150 req/min)
Route::middleware(['throttle:general'])->group(function () {
    require __DIR__.'/api/notification.php';
    require __DIR__.'/api/poll.php';
    ***
});

Configuración de CORS y Expiración de Tokens

Además del limitador de tasa de peticiones, la seguridad de la API debe complementarse con las siguientes configuraciones de infraestructura:

Intercambio de Recursos de Origen Cruzado (CORS)

Las políticas CORS (configuradas en Laravel dentro del archivo config/cors.php) restringen qué dominios del navegador pueden consumir la API. Es importante recordar que las aplicaciones móviles nativas no están sujetas a las restricciones del navegador, por lo que no utilizan el mecanismo CORS; para estas plataformas se deben implementar verificaciones por firma digital (HMAC).

Expiración de JSON Web Tokens (JWT / Sanctum)

Todos los tokens de autenticación deben tener un tiempo de vida finito. En el archivo config/sanctum.php, el tiempo de expiración se especifica en minutos. Por ejemplo, para configurar un periodo de validez de 30 días:

// 30 días * 24 horas * 60 minutos
'expiration' => 43200,

Establecer un tiempo límite de expiración garantiza que, si un token llega a ser interceptado, quede invalidado automáticamente tras expirar el periodo configurado.

Diferencia entre Rate Limiting en Aplicación y Protección a Nivel de Servidor

El mecanismo de Rate Limiting dentro del framework se ejecuta a nivel de aplicación (capa de código PHP). Si bien bloquea la ejecución de la lógica de negocio y el acceso a la base de datos, el servidor web (Nginx o Apache) sigue procesando las peticiones HTTP entrantes.

Para mitigar ataques masivos de denegación de servicio (DDoS), la limitación debe configurarse en capas superiores de la infraestructura, como el servidor web (mediante limit_req en Nginx) o a través de un cortafuegos de aplicaciones web (WAF) como Cloudflare.

Veamos como proteger la Rest API empleando Laravel Sanctum.

Aprende que es una Rest API, porqué son imprescindibles para interconectar sistemas, creamos la primera tipo CRUD, hablamos sobre como modularizarlas y PROTEGERLAS.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.