Conceptos básicos de seguridad en Laravel paso a paso

4 puntos clave + 1 extra para blindar tu aplicación Laravel.

  • laravel
  • php
  • backend
  • seguridad
Icono de seguridad con escudo y candado

Foto de Pixabay en Pexels

De siempre me ha gustado desarrollar apps que tuvieran un código “bonito” pero sobretodo seguro. Me gusta Laravel porque incluye muchas protecciones de seguridad activadas por defecto, pero saber por qué existen y cómo configurarlas correctamente marca la diferencia entre una app robusta y una con las puertas abiertas por donde los bugs pueden campar a sus anchas. Repasamos cuatro puntos que deberías tener bajo control en Laravel (este artículo lo he escrito usando la última versión de Laravel disponible hasta la fecha, la 13).

Los ejemplos de este artículo usan la estructura que introdujo Laravel 11 y que se mantiene en las versiones 12 y 13: el llamado slim skeleton. Esto mola mucho porque a partir de Laravel 11 el proyecto recién creado viene con muchos menos archivos que en versiones anteriores: quitaron el app/Http/Kernel.php y RouteServiceProvider, y el punto de entrada para configurar middleware, rutas y servicios es ahora bootstrap/app.php. Si vienes de Laravel 10 o anterior tienes que tener en cuenta esto, yo tuve que aprenderlo por las malas haciendo una migración del difunto Lumen (v7) a la última versión de Laravel.

1. Protección CSRF - Ataques desde un usuario autenticado que no tiene ni idea de qué está pasando

CSRF (Cross-Site Request Forgery) es un ataque en el que una página maliciosa hace que el navegador de un usuario envíe peticiones a tu app sin que él lo sepa. Laravel lo previene generando un token único por sesión que debe acompañar a cada petición que modifique estado.

La protección está activa por defecto. En tus formularios Blade añade la directiva:

<form method="POST" action="/profile">
    @csrf
    <!-- Equivale a ... -->
    <input type="hidden" name="_token" value="{{ csrf_token() }}" />
</form>

Esto inyecta un campo oculto _token que Laravel valida automáticamente. Si usas un frontend desacoplado (Vue, React), envía el token en la cabecera X-XSRF-TOKEN; Axios lo hace solo si lees la cookie XSRF-TOKEN.

Para excluir rutas de la validación (p.ej. webhooks de terceros), configúralo en bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->preventRequestForgery(except: [
        'stripe/*',
        'http://example.com/foo/bar',
        'http://example.com/foo/*',
    ]);
})

Más info

2. Rate Limiting — Protección contra fuerza bruta

Sin límite de intentos, un atacante puede probar miles de contraseñas contra tu formulario de login hasta dar con la correcta. En Laravel los limitadores se definen en AppServiceProvider y se aplican por nombre en las rutas.

Define el limitador en app/Providers/AppServiceProvider.php:

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

public function boot(): void
{
    RateLimiter::for('login', function (Request $request) {
        return Limit::perMinute(5)
            ->by($request->input('email') . '|' . $request->ip())
            ->response(function (Request $request, array $headers) {
                return response()->json([
                    'message' => 'Demasiados intentos. Espera un minuto.',
                ], 429, $headers);
            });
    });
}

Y aplícalo en la ruta:

Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:login');

Usar email|ip como clave evita que bloquear un email concreto afecte a otros usuarios desde la misma IP y viceversa.

Más info

3. SQL Injection - No expongas tu base de datos o puedes tener fugas de información

Uno de los ataques más comunes, la inyección SQL ocurre cuando se incluye input del usuario directamente en una consulta sin sanitizar. El atacante puede leer, modificar o borrar datos de tu base de datos.

Nunca construyas consultas concatenando strings con datos del usuario:

// ❌ Nunca hagas esto
$user = DB::select("SELECT * FROM users WHERE email = '$email'");

// ❌ Tampoco esto
User::whereRaw("email = '$email'")->first();

sql injection Fuente: somethingofthatilk.com

Usa siempre el Query Builder o Eloquent, que enlazan los valores como parámetros:

// ✅ Query Builder con binding
$user = DB::select('SELECT * FROM users WHERE email = ?', [$email]);

// ✅ Eloquent (seguro por defecto)
$user = User::where('email', $email)->first();

// ✅ whereRaw con binding explícito si es necesario
User::whereRaw('YEAR(created_at) = ?', [$year])->get();

Laravel utiliza PDO con prepared statements, lo que garantiza que el valor nunca se interprete como SQL. El ORM y el Query Builder te cubren en el 99% de los casos; whereRaw solo debería aparecer para expresiones SQL que no tienen equivalente en el builder, y siempre con bindings.

Más info

4. Headers de seguridad HTTP

Las cabeceras HTTP de seguridad le dicen al navegador cómo comportarse ante ciertos ataques. Laravel no las añade por defecto, pero puedes hacerlo con un middleware.

Crea app/Http/Middleware/SecurityHeaders.php:

<?php

namespace App\Http\Middleware;

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

class SecurityHeaders
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        // Evita que el navegador adivine el tipo MIME del archivo (previene XSS por sniffing)
        $response->headers->set('X-Content-Type-Options', 'nosniff');

        // Impide que tu app se cargue en cualquier <iframe>, independientemente del dominio (previene clickjacking)
        $response->headers->set('X-Frame-Options', 'DENY');

        // Controla qué información de la URL anterior se incluye al navegar a otro sitio
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');

        // Desactiva APIs del navegador que la app no necesita (cámara, micrófono, geolocalización)
        $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');

        // Define desde qué orígenes se pueden cargar recursos (scripts, estilos, imágenes...)
        $response->headers->set(
            'Content-Security-Policy',
            "default-src 'self'; form-action 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests;"
        );

        return $response;
    }
}

En Laravel 13 el middleware se registra en bootstrap/app.php, no en Kernel.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \App\Http\Middleware\SecurityHeaders::class,
    ]);
})

Qué hace cada cabecera:

  • X-Content-Type-Options: nosniff — evita que el navegador intente adivinar el tipo MIME de un archivo (vector de ataques XSS).
  • X-Frame-Options: DENY — impide que tu app sea embebida en cualquier <iframe>, sin excepción (previene clickjacking).
  • Referrer-Policy — controla qué información de la URL anterior se envía en la cabecera Referer.
  • Permissions-Policy — deshabilita APIs del navegador que tu app no usa (cámara, micrófono, geolocalización).
  • Content-Security-Policy — define desde qué orígenes se pueden cargar recursos. Es la más potente y la que requiere más ajuste fino para no romper nada.

Más info · Referencia OWASP Secure Headers

Extra - 5. XSS — cuando te cuelan código en tu web que se ejecuta en el navegador de tus usuarios

XSS (Cross-Site Scripting) consiste en inyectar código JavaScript malicioso que se ejecuta en el navegador de otros usuarios. En Laravel, Blade escapa automáticamente cualquier variable con {{ }}:

{{-- ✅ Escapado automático --}}
<p>{{ $comentario }}</p>

{{-- ❌ Sin escapado: solo para HTML de confianza, nunca con input de usuario --}}
<p>{!! $contenidoDeConfianza !!}</p>

{{ $variable }} equivale a htmlspecialchars($variable, ENT_QUOTES). Si un usuario introduce <script>alert('xss')</script>, Blade lo renderizará como texto plano, no como código.

Reglas básicas:

  • Usa {{ }} siempre que muestres datos del usuario.
  • Reserva {!! !!} para HTML generado por tu propia aplicación, por ejemplo contenido de un “rich text editor” (o editor de texto enriquecido, aquí hablamos tanto en español como en inglés, chaval) que ya has sanitizado tú.
  • Si necesitas sanitizar HTML de entrada (editores WYSIWYG), usa un paquete como mews/purifier que aplica HTMLPurifier.
  • Valida y rechaza en la capa de FormRequest antes de que el dato llegue a la base de datos.

Más info


Mucho texto bro, resume un poco

  1. CSRF — Laravel genera un token por sesión que valida cada petición que modifique datos. Con @csrf en el formulario, ya tienes esto cubierto.
  2. Rate Limiting — Define un límite de intentos por email y por IP en AppServiceProvider y aplícalo con throttle:login. Sin esto, tu formulario de login es un blanco fácil.
  3. SQL Injection — Nunca concatenes input del usuario en una consulta SQL. Eloquent y el Query Builder usan prepared statements y te protegen por defecto; whereRaw solo con bindings explícitos.
  4. Headers de seguridad — Un middleware con cinco cabeceras HTTP le dice al navegador cómo protegerse: sin sniffing de MIME, sin iframes, sin APIs innecesarias y con una política de orígenes estricta.
  5. XSS — Blade escapa todo lo que metes entre {{ }}. Usa {!! !!} solo para HTML que hayas generado y sanitizado tú mismo.