Prácticas Recomendadas de Seguridad en Laravel
Guía completa de seguridad para aplicaciones Laravel para proteger contra vulnerabilidades comunes.
Cuándo activar
- Configurar autenticación y autorización en Laravel (Sanctum, Passport, Jetstream, Breeze)
- Implementar roles de usuario, permisos y políticas
- Configurar ajustes de seguridad en producción y variables de entorno
- Revisar aplicaciones Laravel en busca de vulnerabilidades de seguridad
- Desplegar aplicaciones Laravel a producción
- Escribir consultas Eloquent seguras y migraciones
Configuración de Producción
Ajustes Esenciales de Producción
// config/app.php
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false), // CRÍTICO: Nunca true en producción
'key' => env('APP_KEY'), // Debe estar definido: php artisan key:generate
// config/session.php
'secure' => env('SESSION_SECURE_COOKIE', true),
'http_only' => true,
'same_site' => 'lax',
// Verificar APP_KEY al arrancar
// bootstrap/app.php o un service provider
if (empty(config('app.key'))) {
throw new RuntimeException('APP_KEY no está definida. Ejecuta: php artisan key:generate');
}
Seguridad del Archivo de Entorno
# NUNCA subir .env al control de versiones
# .gitignore ya incluye .env por defecto
# Usa .env.example con marcadores de posición en su lugar
DB_PASSWORD=
APP_KEY=
SANCTUM_TOKEN_PREFIX=
# Validar variables requeridas al arrancar
// En AppServiceProvider::boot()
$requiredKeys = ['app.key', 'database.connections.mysql.database', 'database.connections.mysql.username'];
foreach ($requiredKeys as $key) {
if (empty(config($key))) {
throw new RuntimeException("Falta la clave de configuración requerida: {$key}");
}
}
Imposición de HTTPS
// AppServiceProvider::boot() o middleware
if (app()->environment('production')) {
URL::forceScheme('https');
request()->server->set('HTTPS', 'on');
}
// config/app.php para proxies confiables (balanceadores de carga)
// Usar rangos IP específicos — * confía en todos, permitiendo suplantación X-Forwarded-*
// AWS: '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'
'trusted_proxies' => ['10.0.0.0/8', '172.16.0.0/12'],
// Forzar HTTPS en producción mediante middleware
// app/Http/Middleware/ForceHttps.php
public function handle($request, Closure $next)
{
if (!$request->secure() && app()->environment('production')) {
return redirect()->secure($request->getRequestUri());
}
return $next($request);
}
Autenticación
Sanctum (Autenticación mediante Token de API)
// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
env('APP_URL') ? ',' . parse_url(env('APP_URL'), PHP_URL_HOST) : ''
)));
'expiration' => 60 * 24, // Expiración del token en minutos (null = nunca)
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
// Emitir tokens con habilidades
$token = $user->createToken('api-token', ['read', 'write'])->plainTextToken;
// Validar habilidades en rutas
Route::middleware('auth:sanctum')->group(function () {
Route::get('/orders', function () {
// El usuario debe tener habilidad 'read'
abort_unless(Auth::user()->tokenCan('read'), 403);
// ...
})->middleware('abilities:read');
Route::post('/orders', function () {
// El usuario debe tener habilidad 'write'
abort_unless(Auth::user()->tokenCan('write'), 403);
// ...
})->middleware('abilities:write');
});
Seguridad de Contraseñas
// config/hashing.php
// El predeterminado es bcrypt. Argon2id es más fuerte.
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 12), // Aumentar para hash más fuerte
],
'argon' => [
'memory' => 65536,
'threads' => 4,
'time' => 4,
],
// Validación de contraseña en RegisterRequest
public function rules(): array
{
return [
'password' => [
'required',
'confirmed',
Password::min(12)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised(), // Verifica en haveibeenpwned
],
];
}
// Limitar tasa de intentos de inicio de sesión
// App\Http\Controllers\Auth\AuthenticatedSessionController
protected function authenticated(Request $request, $user)
{
if ($user->wasRecentlyLockedOut()) {
// Notificar al usuario sobre inicio de sesión sospechoso
$user->notify(new SuspiciousLoginNotification($request->ip()));
}
}
Gestión de Sesiones
// config/session.php
'driver' => env('SESSION_DRIVER', 'database'), // database/redis > file
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
'encrypt' => env('SESSION_ENCRYPT', false),
// Regenerar sesión al iniciar sesión
// App\Http\Controllers\Auth\AuthenticatedSessionController
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate(); // CRÍTICO: previene fijación de sesión
return redirect()->intended(RouteServiceProvider::HOME);
}
// Invalidar sesión al cerrar sesión
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
Autorización
Puertas (Gates)
// App\Providers\AuthServiceProvider
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::define('update-post', function (User $user, Post $post): bool {
return $user->id === $post->user_id;
});
Gate::define('publish-post', function (User $user): bool {
return $user->role === 'editor' || $user->role === 'admin';
});
// Usar before() para anulación de super-admin
Gate::before(function (User $user, string $ability): ?bool {
if ($user->role === 'super-admin') {
return true; // Otorga todas las habilidades
}
return null; // Continuar con verificaciones normales
});
}
// Uso en controladores
public function update(Request $request, Post $post): RedirectResponse
{
Gate::authorize('update-post', $post);
// O: $this->authorize('update-post', $post);
// O: abort_unless(Auth::user()->can('update-post', $post), 403);
// ...
}
Políticas
// App\Policies\PostPolicy
class PostPolicy
{
use HandlesAuthorization;
public function viewAny(?User $user): bool
{
return true; // Listado público
}
public function view(?User $user, Post $post): bool
{
return $post->is_published || ($user && $user->id === $post->user_id);
}
public function create(User $user): bool
{
return $user->hasVerifiedEmail(); // Debe verificar correo primero
}
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id && $post->created_at->diffInDays(now()) <= 30;
}
public function restore(User $user, Post $post): bool
{
return $user->role === 'admin';
}
public function forceDelete(User $user, Post $post): bool
{
return $user->role === 'super-admin';
}
}
// Registrar en AuthServiceProvider
protected $policies = [
Post::class => PostPolicy::class,
];
// Uso en controlador
public function show(Post $post): View
{
$this->authorize('view', $post);
return view('posts.show', compact('post'));
}
// Uso en Blade
@can('update', $post)
<a href="{{ route('posts.edit', $post) }}">Editar</a>
@endcan
@cannot('update', $post)
<span>No puedes editar esta publicación</span>
@endcannot
Autorización mediante Middleware
// Usar middleware en rutas
Route::put('/posts/{post}', [PostController::class, 'update'])
->middleware('can:update,post');
Route::get('/posts/create', [PostController::class, 'create'])
->middleware('can:create,App\Models\Post');
// Middleware de autorización personalizado
// app/Http/Middleware/CheckRole.php
class CheckRole
{
public function handle(Request $request, Closure $next, string $role): mixed
{
if (!$request->user() || $request->user()->role !== $role) {
abort(403, 'No autorizado. Esta área requiere el rol: ' . $role);
}
return $next($request);
}
}
// Registrar en Kernel
protected $routeMiddleware = [
'role' => \App\Http\Middleware\CheckRole::class,
];
// Uso en rutas
Route::middleware(['auth', 'role:admin'])->group(function () {
Route::get('/admin', [AdminController::class, 'index']);
});
Seguridad de Eloquent
Protección contra Asignación Masiva
// MALO: $guarded = [] permite que TODAS las columnas sean asignadas masivamente
// NUNCA uses $guarded = [] en producción
// BUENO: Lista blanca de atributos fillable
final class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'phone',
'avatar',
];
// NUNCA agregues 'role', 'is_admin', 'is_verified' aquí
}
// BUENO: Controlar explícitamente qué campos se pueden llenar en las solicitudes
public function store(StoreUserRequest $request): RedirectResponse
{
$user = User::create($request->safe()->only([
'name', 'email', 'phone', 'avatar'
]));
// $request->safe() solo usa datos validados
// $request->only() NO es seguro por sí mismo sin reglas de validación
}
// MALO: Crear un usuario directamente con datos de la solicitud
User::create($request->all()); // VULNERABLE a asignación masiva!
// MEJOR: Usar DTO para la creación
$user = User::create($request->validated()); // Solo campos validados
Prevención de Inyección SQL
// BUENO: Eloquent parametriza consultas automáticamente
User::where('email', $userInput)->first();
User::whereRaw('email = ?', [$userInput])->first();
// BUENO: Query Builder también parametriza
DB::table('users')->where('email', $userInput)->first();
DB::select('SELECT * FROM users WHERE email = ?', [$userInput]);
// MALO: Interpolación directa de cadenas
DB::select("SELECT * FROM users WHERE email = '{$userInput}'"); // VULNERABLE!
User::whereRaw("email = '{$userInput}'")->first(); // VULNERABLE!
// MALO: whereRaw/orderByRaw con entrada sin escapar
User::orderByRaw($userInput); // VULNERABLE!
User::groupByRaw($userInput); // VULNERABLE!
// MALO: DB::statement con concatenación
DB::statement("INSERT INTO users (email) VALUES ('{$userInput}')"); // VULNERABLE!
Casting de Atributos
final class User extends Authenticatable
{
protected $casts = [
'email_verified_at' => 'datetime',
'is_admin' => 'boolean', // Cast a booleano previene inyección de cadenas
'settings' => 'array', // Automáticamente json_encode/json_decode
'metadata' => 'encrypted:array', // Laravel 11+ casting encriptado
'password' => 'hashed', // Laravel 10+ hashea automáticamente al asignar
];
}
Seguridad del Modelo
final class User extends Authenticatable
{
// Ocultar atributos sensibles de respuestas JSON/API
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
];
// Agregar solo atributos computados seguros
protected $appends = ['full_name']; // seguro
// NUNCA agregues datos sensibles computados
}
final class Post extends Model
{
// Alcance global para filtrar registros eliminados suavemente
use SoftDeletes;
// Prevenir N+1 restringiendo carga perezosa (modo estricto opcional)
// AppServiceProvider::boot()
// Model::preventLazyLoading(!app()->isProduction());
}
Protección CSRF
Protección por Defecto
// CSRF de Laravel está habilitado por defecto mediante el middleware VerifyCsrfToken
// app/Http/Kernel.php (protected $middlewareGroups['web'])
// Todos los formularios POST/PUT/PATCH/DELETE deben incluir @csrf
<form method="POST" action="/posts">
@csrf
<input type="text" name="title">
<button type="submit">Crear</button>
</form>
Excluir Rutas (Con Cuidado)
// app/Http/Middleware/VerifyCsrfToken.php
class VerifyCsrfToken extends Middleware
{
// Excluir solo rutas que tienen protección CSRF externa (webhooks, etc.)
protected $except = [
'stripe/*', // Los webhooks de Stripe usan su propia verificación de firma
// Evita excluir 'api/*' de forma general — las rutas de Sanctum con estado necesitan CSRF.
// Excluye solo rutas específicas de webhooks/endpoints sin estado.
];
}
CSRF con JavaScript
<meta name="csrf-token" content="{{ csrf_token() }}">
<script>
// Ejemplo con Axios (Laravel viene con Axios)
axios.defaults.headers.common['X-CSRF-TOKEN'] = document.querySelector(
'meta[name="csrf-token"]'
).getAttribute('content');
// Ejemplo con Fetch
fetch('/posts', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
</script>
Prevención de XSS
Seguridad en Plantillas Blade
{{-- SEGURO: Escapado automáticamente por Blade --}}
{{ $userInput }}
{{-- PELIGROSO: Salida sin escapar — NUNCA lo uses con entrada del usuario --}}
{!! $userInput !!}
{{-- SEGURO: Usa {!! !!} solo con contenido confiable que tú controles --}}
{!! $trustedHtmlFromYourServer !!}
{{-- BUENO: Usa directivas de escape específicas --}}
@js($data) {{-- Codificar JSON para JavaScript --}}
@json($data) {{-- Codificar JSON en plantillas --}}
{{-- MALO: Entrada directa del usuario en HTML sin escapar --}}
<div>{!! $user->bio !!}</div> {{-- VULNERABLE si el usuario proporciona la biografía --}}
Manejo Seguro de HTML
// Cuando debas permitir algo de HTML, usa un enfoque de lista blanca
use HTMLPurifier; // Requiere: composer require ezyang/htmlpurifier
public function sanitizeHtml(string $dirty): string
{
$config = \HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,b,i,a[href],ul,ol,li,br');
$config->set('URI.AllowedSchemes', ['http', 'https', 'mailto']);
$purifier = new \HTMLPurifier($config);
return $purifier->purify($dirty);
}
// En blade:
<div>{!! $sanitizedHtml !!}</div> {{-- SEGURO después de purificar --}}
Content Security Policy (CSP)
// app/Http/Middleware/ContentSecurityPolicy.php
class ContentSecurityPolicy
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->header('Content-Security-Policy', "
default-src 'self';
script-src 'self' 'nonce-" . base64_encode(random_bytes(16)) . "';
style-src 'self' 'unsafe-inline';
img-src 'self' https:;
font-src 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
");
return $response;
}
}
// Registrar en Kernel para rutas web
protected $middlewareGroups = [
'web' => [
// ... otros middlewares
\App\Http\Middleware\ContentSecurityPolicy::class,
],
];
Seguridad de API
Limitación de Tasa
// app/Http/Kernel.php
protected $middlewareGroups = [
'api' => [
'throttle:api',
// ...
],
];
// config/app.php
'api' => [
// ...
'rate_limits' => [
'api' => 60, // Límite por minuto
'login' => 5, // Límite más estricto para inicios de sesión
],
],
// Limitación de tasa personalizada por ruta
Route::middleware('throttle:10,1')->group(function () { // 10 solicitudes por minuto
Route::get('/sensitive-data', [SensitiveDataController::class, 'index']);
});
Seguridad de CORS
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '')),
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => ['X-CSRF-TOKEN'],
'max_age' => 0,
'supports_credentials' => true,
];
Protección de Rutas de API
// Usar Sanctum para autenticación SPA
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', function (Request $request) {
return $request->user();
});
Route::apiResource('/posts', PostController::class)
->middleware('can:view,post');
});
// Validación de entrada para API
// app/Http/Requests/Api/StorePostRequest.php
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Autenticación manejada por middleware
}
public function rules(): array
{
return [
'title' => 'required|string|max:255|not_regex:/<[^>]*>/',
'body' => 'required|string',
'category_id' => 'required|exists:categories,id',
];
}
}
Seguridad en Despliegue
Archivo .env Seguro
# Generar clave de aplicación segura al desplegar
php artisan key:generate --force
# Usar variables de entorno del servidor en lugar de .env en producción
# Configurar en el proveedor de hosting (Forge, Envoyer, etc.)
# Rotar claves de API y secretos regularmente
# No almacenar secretos en repositorios de código
Seguridad de la Base de Datos
// config/database.php
'mysql' => [
// ...
'sslmode' => env('DB_SSLMODE', 'prefer'),
'options' => extension_loaded('pdo_mysql') ? [
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
] : [],
],
// Usar cuentas de base de datos separadas
// - cuenta_readonly: solo SELECT
// - cuenta_readwrite: SELECT, INSERT, UPDATE, DELETE
// - cuenta_admin: todas las operaciones
// En modelos:
// Asignar cuenta específica
class User extends Authenticatable
{
protected $connection = 'mysql_readwrite';
}
Registro de Seguridad
// config/logging.php
'channels' => [
'security' => [
'driver' => 'single',
'path' => storage_path('logs/security.log'),
'level' => 'info',
],
],
// Registrar eventos de seguridad
Log::channel('security')->info('Intento de inicio de sesión sospechoso', [
'user_id' => $user->id,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'timestamp' => now(),
]);
// Monitorear fallos de autorización
Log::channel('security')->warning('Intento de acceso no autorizado', [
'user_id' => Auth::check() ? Auth::id() : null,
'route' => $request->route()->uri(),
'method' => $request->method(),
'ip' => $request->ip(),
]);
Vulnerabilidades Comunes
Inyección SQL
Síntomas: Consultas que aceptan entrada del usuario sin parametrizar Prevención: Usar siempre Eloquent o Query Builder con parámetros vinculados
Asignación Masiva
Síntomas: Usar $request->all() en create/update Prevención: Definir $fillable o usar $request->validated() con reglas de validación
Exposición de Datos Sensibles
Síntomas: Contraseñas, tokens o claves API en respuestas Prevención: Usar $hidden en modelos, no exponer IDs internos
Fijación de Sesión
Síntomas: No regenerar session_id después del inicio de sesión Prevención: Llamar $request->session()->regenerate() en inicio de sesión
Cross-Site Scripting (XSS)
Síntomas: Salida sin escapar de entrada del usuario en vistas Prevención: Usar sintaxis Blade {{ }} para escapar, evitar {!! !!} con datos del usuario
Cross-Site Request Forgery (CSRF)
Síntomas: Formularios sin @csrf o rutas API sin protección CSRF Prevención: Incluir @csrf en formularios, usar Sanctum para SPA
Inyección de Dependencias (Dependency Injection)
Síntomas: Servicios no confiables inyectados sin validación Prevención: Validar y sanitizar todas las entradas antes de la inyección
Dependencias de Composer para Seguridad
# Escáner de dependencias vulnerables
composer require --dev enlightn/enlightn
# Firewall de aplicaciones
composer require akaunting/laravel-firewall
# Purificador HTML
composer require ezyang/htmlpurifier
# Validación de IP
composer require mmanos/laravel-ip-middleware