Mejores Prácticas de Seguridad en Laravel
Guía completa de seguridad para aplicaciones Laravel con el fin de proteger contra vulnerabilidades comunes.
Cuándo Activar
- Al configurar autenticación y autorización en Laravel (Sanctum, Passport, Jetstream, Breeze)
- Al implementar roles de usuario, permisos y políticas
- Al configurar ajustes de seguridad para producción y variables de entorno
- Al revisar aplicaciones Laravel en busca de vulnerabilidades de seguridad
- Al desplegar aplicaciones Laravel a producción
- Al escribir consultas y migraciones seguras con Eloquent
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 definida: php artisan key:generate
// config/session.php
'secure' => env('SESSION_SECURE_COOKIE', true),
'http_only' => true,
'same_site' => 'lax',
// Verificar que APP_KEY esté definida al iniciar
// bootstrap/app.php o un proveedor de servicios
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 valores de relleno
DB_PASSWORD=
APP_KEY=
SANCTUM_TOKEN_PREFIX=
# Validar variables requeridas al iniciar
// 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}");
}
}
Forzar HTTPS
// AppServiceProvider::boot() o middleware
if (app()->environment('production')) {
URL::forceScheme('https');
request()->server->set('HTTPS', 'on');
}
// config/app.php para proxies de confianza (balanceadores de carga)
// Usa 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 por Token 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
// Por defecto es bcrypt. Argon2id es más fuerte.
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 12), // Aumentar para hash más seguro
],
'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 intentos de inicio de sesión
// App\Http\Controllers\Auth\AuthenticatedSessionController
protected function authenticated(Request $request, $user)
{
if ($user->wasRecentlyLockedOut()) {
// Notificar al usuario de 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
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; // Pasar a 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 email 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 este post</span>
@endcannot
Autorización con 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 personalizado de autorización
// 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 en Eloquent
Protección contra Asignación Masiva
// MALO: $guarded = [] permite que TODAS las columnas se asignen 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 agregar '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() usa solo datos validados
// $request->only() NO es seguro por sí solo 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 DTOs 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 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', // Convertir a booleano previene inyección de cadenas
'settings' => 'array', // Automáticamente json_encode/json_decode
'metadata' => 'encrypted:array', // Casting cifrado en Laravel 11+
'password' => 'hashed', // Laravel 10+ auto-hashea al asignar
];
}
Seguridad en Modelos
final class User extends Authenticatable
{
// Ocultar atributos sensibles de respuestas JSON/API
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
];
// Añadir solo atributos calculados seguros
protected $appends = ['full_name']; // seguro
// NUNCA añadir datos calculados sensibles
}
final class Post extends Model
{
// Scope global para filtrar registros con soft delete
use SoftDeletes;
// Prevenir N+1 restringiendo carga diferida (modo estricto opcional)
// AppServiceProvider::boot()
// Model::preventLazyLoading(!app()->isProduction());
}
Protección CSRF
Protección por Defecto
// El 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
{
// Solo excluir rutas que tengan protección CSRF externa (webhooks, etc.)
protected $except = [
'stripe/*', // Los webhooks de Stripe usan su propia verificación de firmas
// Evita excluir 'api/*' de forma general — las rutas stateful de Sanctum necesitan CSRF.
// Excluye solo rutas específicas sin estado o de endpoints de webhook.
];
}
CSRF con JavaScript
<meta name="csrf-token" content="{{ csrf_token() }}">
<script>
// Ejemplo con Axios (Laravel incluye 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ático por Blade --}}
{{ $userInput }}
{{-- PELIGROSO: Salida sin escapar — NUNCA usar con entrada de usuario --}}
{!! $userInput !!}
{{-- SEGURO: Usar {!! !!} solo con contenido confiable que tú controlas --}}
{!! $trustedHtmlFromYourServer !!}
{{-- BUENO: Usar directivas específicas de escapado --}}
@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>{!! $sanitizedContent !!}</div> {{-- SEGURO después de purificar --}}
Seguridad en APIs
Throttling (Límite de Peticiones)
// app/Http/Kernel.php
protected $middlewareGroups = [
'api' => [
\Illuminate\Routing\Middleware\ThrottleRequests::class . ':api',
// Límite por defecto: 60 peticiones por minuto
],
];
// Límites personalizados en rutas
Route::middleware('throttle:30,1')->group(function () {
Route::post('/api/orders', [OrderController::class, 'store']);
Route::post('/api/reviews', [ReviewController::class, 'store']);
});
// Límite por IP usando middleware dinámico
Route::middleware('throttle:100,1,ip')->group(function () {
Route::get('/api/users', [UserController::class, 'index']);
});
CORS
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => env('CORS_ALLOWED_ORIGINS')
? explode(',', env('CORS_ALLOWED_ORIGINS'))
: [env('APP_URL')], // Limitar a orígenes específicos
'allowed_headers' => ['*'],
'supports_credentials' => true, // Requerido para Sanctum stateful
];
Autenticación de API con Passport
// config/auth.php
'guards' => [
'api' => [
'driver' => 'passport',
'provider' => 'users',
],
],
// Crear cliente con scopes
use Laravel\Passport\Client;
$client = Client::create([
'user_id' => $user->id,
'name' => 'Mi App',
'redirect' => env('APP_URL') . '/callback',
'personal_access_client' => false,
'password_client' => false,
'revoked' => false,
]);
// Middleware de scopes
Route::middleware('auth:api')->group(function () {
Route::get('/profile', function () {
// Verificar scope 'user-profile'
abort_unless(Auth::user()->tokenCan('user-profile'), 403);
})->middleware('scope:user-profile');
});
Configuración de Despliegue Seguro
Variables de Entorno Esenciales para Producción
# .env.production
APP_ENV=production
APP_DEBUG=false
APP_KEY= # Ejecutar: php artisan key:generate --force
DB_HOST= # No exponer localhost; usar endpoint de RDS
DB_CONNECTION=mysql
DB_DATABASE= # Base de datos dedicada
DB_USERNAME= # Usuario con privilegios mínimos
DB_PASSWORD= # Contraseña segura (>20 caracteres)
SESSION_DRIVER=redis # Redis > database > file en producción
SESSION_SECURE_COOKIE=true
SANCTUM_STATEFUL_DOMAINS=midominio.com,admin.midominio.com
MAIL_DRIVER=smtp
MAIL_ENCRYPTION=tls
Seguridad del Servidor Web (Nginx)
# Bloquear archivos sensibles
location ~*\.(env|log|sql|yml|yaml|json|md)$ {
deny all;
return 404;
}
# Limitar métodos HTTP
if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE|PATCH|OPTIONS)$) {
return 405;
}
# Bloquear acceso a directorios del framework
location ~ /(vendor|storage|bootstrap|resources|public/storage)/.*\.php$ {
deny all;
return 404;
}
# Tamaño máximo de subida
client_max_body_size 10M;
Seguridad de Cabeceras HTTP
// app/Http/Middleware/SecureHeaders.php
final class SecureHeaders
{
public function handle(Request $request, Closure $next): mixed
{
$response = $next($request);
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Permitted-Cross-Domain-Policies', 'none');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
// CSP (Política de Seguridad de Contenido) - ajustar según necesidades
$response->headers->set('Content-Security-Policy',
"default-src 'self'; " .
"script-src 'self' 'unsafe-inline'; " .
"style-src 'self' 'unsafe-inline'; " .
"img-src 'self' data: https:; " .
"font-src 'self'; " .
"connect-src 'self'; " .
"frame-ancestors 'none';"
);
// HSTS (HTTP Strict Transport Security) - habilitar solo con HTTPS configurado
// $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
// Eliminar cabeceras que revelan información del servidor
header_remove('X-Powered-By');
header_remove('Server');
return $response;
}
}
// Registrar en Kernel
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\SecureHeaders::class,
// ... otros middlewares
],
];
Monitoreo de Seguridad
Registro de Actividad
// En AppServiceProvider::boot()
\Illuminate\Database\Eloquent\Model::created(function ($model) {
\Log::info('Modelo creado', [
'model' => get_class($model),
'id' => $model->id,
'user_id' => auth()->id(),
'ip' => request()->ip(),
]);
});
// Logger de actividad personalizado
// app/Http/Middleware/LogActivity.php
public function handle(Request $request, Closure $next): mixed
{
$response = $next($request);
if ($request->isMethod('post') || $request->isMethod('put') || $request->isMethod('delete')) {
\Log::channel('security')->info('Actividad del usuario', [
'user_id' => auth()->id(),
'method' => $request->method(),
'url' => $request->fullUrl(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'status' => $response->getStatusCode(),
]);
}
return $response;
}
Alertas de Seguridad
// app/Notifications/SuspiciousActivity.php
final class SuspiciousActivity extends Notification
{
use Queueable;
public function __construct(
private readonly string $type,
private readonly array $context
) {}
public function via(object $notifiable): array
{
return ['mail', 'slack'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Alerta de Seguridad: ' . $this->type)
->line('Se ha detectado una actividad sospechosa.')
->line('Tipo: ' . $this->type)
->line('IP: ' . ($this->context['ip'] ?? 'Desconocida'))
->line('Usuario: ' . ($this->context['user_id'] ?? 'No autenticado'))
->action('Panel de Administración', url('/admin'));
}
public function toSlack(object $notifiable): SlackMessage
{
return (new SlackMessage)
->warning()
->content('⚠️ Alerta de Seguridad: ' . $this->type)
->attachment(function ($attachment) {
$attachment->fields($this->context);
});
}
}
// Uso
\Notification::route('slack', config('services.slack.security_webhook'))
->notify(new SuspiciousActivity('Múltiples intentos fallidos de inicio de sesión', [
'ip' => request()->ip(),
'email' => $request->email,
'user_agent' => $request->userAgent(),
]));
Verificación Rápida de Seguridad
Antes de lanzar a producción, verifica:
-
APP_DEBUG=falseen producción -
APP_KEYestá definida y es única por entorno - HTTPS está configurado (proxy o servidor web)
-
.envno está en el control de versiones -
$fillableo$guardedestán configurados en todos los modelos - CSRF está habilitado en todos los formularios y solicitudes AJAX
- Las consultas usan vinculación de parámetros (sin interpolación de cadenas)
- Las contraseñas están hasheadas (bcrypt/argon)
- Throttling configurado en rutas de inicio de sesión/API
- Política de Contenido de Seguridad (CSP) configurada
- Cabeceras de seguridad HTTP aplicadas
- Los roles de base de datos tienen privilegios mínimos
- Los archivos de sesión usan database/redis (no file en producción)
- Los tokens de Sanctum/Passport tienen fecha de expiración
- Las dependencias están actualizadas (
composer audit) - Las migraciones están revisadas en busca de columnas sensibles