[De principiante a producción] Construir autenticación y autorización en Laravel de la forma correcta
Inicio de sesión/Registro, Sanctum, Policy/Gate, diseño de roles, seguridad y formularios accesibles e interfaz de errores
- Este artículo es para quienes quieren implementar login en Laravel pero no están seguros de cuál es “la forma correcta”. Organiza autenticación (Auth) y autorización (Authorization) como un único diseño continuo.
- Como mejora a la vez la UI (formularios) y el backend (sesiones/tokens/permisos), te ayuda a evitar errores comunes del mundo real.
- Incluye ejemplos prácticos que tratan la accesibilidad (lectores de pantalla, navegación por teclado, presentación de errores) como el estándar por defecto.
Lectores previstos (¿a quién beneficia?)
- Nuevos en Laravel: no entiendes las diferencias entre Breeze/Jetstream/Sanctum y no sabes por dónde empezar
- Desarrolladores de apps web pequeñas/medianas: el login/permisos se volvió ad hoc y operaciones empieza a doler
- Leads/revisores: quieres reglas de autorización consistentes en código y menos “accidentes” de UI (ver datos que no deberías)
- Diseñadores/QA/accesibilidad: quieres formularios y flujos de login en los que nadie se quede atascado
Nivel de accesibilidad: ★★★★★
- Asociación correcta etiqueta–input, resúmenes de error,
aria-invalid/aria-describedby, y señales de requerido/error que no dependen del color - Guía amable para expiración de sesión (419) y permisos insuficientes (403), incluyendo el “siguiente paso”
- Diseñado para que los usuarios completen login → cambios de configuración solo con teclado
1. Autenticación y autorización son distintas, pero diseñarlas juntas te hace más fuerte
Cuando empiezas a construir una app en Laravel, lo primero que normalmente quieres es el login. Pero justo después de que el login funciona, aparece la siguiente pregunta inevitable: “¿Esta persona puede hacer esto?” Si pospones la autorización, cuantas más pantallas y APIs agregues, más verificaciones se te van a escapar. Así ocurren los incidentes de permisos (ver datos que no deberías, editar cuando no corresponde).
Así que desde el inicio, trátalas como un par:
- Autenticación: ¿quién eres? (identidad vía login/tokens)
- Autorización: ¿qué se te permite hacer? (chequeos de permisos, de propiedad, roles)
El objetivo de este artículo es ayudar a principiantes a pasar de “funciona” a una configuración de auth que puedas operar con seguridad en producción.
2. Primera decisión: Breeze vs Jetstream vs Fortify vs hacerlo tú mismo
Laravel ofrece varios enfoques. En la mayoría de los casos, el primer paso más seguro y simple es Breeze.
- Laravel Breeze
- Minimalista; incluye login/registro/restablecimiento de contraseña, etc.
- La versión Blade es fácil de leer y amigable para principiantes
- Laravel Jetstream
- Muy completo (equipos, 2FA, etc.)
- Excelente si necesitas todo desde el día 1, pero el costo de aprendizaje es mayor
- Laravel Fortify
- Backend de auth sin UI (para SPA o UI personalizada)
- Implementación personalizada
- Buena para aprender, pero arriesgada en producción: puntos críticos (CSRF, fijación de sesión, límites de tasa, etc.) son fáciles de omitir
Si quieres el camino más corto hacia una base segura: construye la base con Breeze (Blade) y luego amplía la autenticación de API con Sanctum cuando lo necesites.
3. Fundamentos de autenticación: flujo de login por sesión (apps web)
3.1 Qué ocurre cuando el login tiene éxito
Para apps web basadas en navegador, el flujo estándar es:
- El formulario envía email + contraseña
- El servidor verifica la identidad (verificación del hash)
- Si tiene éxito, se crea una sesión y el ID de sesión mantiene el estado de login
- La protección CSRF trabaja junto con los formularios para mantener los envíos seguros
Laravel soporta muy bien este mecanismo estándar, así que normalmente es más seguro no sobre-personalizarlo.
3.2 Ajustes importantes en .env
- Asegúrate de que
APP_KEYesté generado - En producción:
APP_DEBUG=false - Si eliges Redis (etc.) para sesiones, incluye consideraciones operativas también
4. Diseño de contraseñas: fortaleza, flujo de reset y mensajes estandarizados
Las contraseñas son centrales para la seguridad, pero reglas demasiado estrictas aumentan la carga de soporte. En la práctica, “un mínimo razonable + un flujo de restablecimiento bien diseñado” funciona mejor.
4.1 Ejemplo de validación (FormRequest)
// app/Http/Requests/RegisterRequest.php
class RegisterRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required','string','max:50'],
'email' => ['required','email','max:255','unique:users,email'],
'password' => [
'required','string','min:12','confirmed',
],
];
}
public function attributes(): array
{
return [
'name' => 'Your name',
'email' => 'Email address',
'password' => 'Password',
];
}
}
4.2 El texto del reset debe reducir “incidentes”
- Aunque el email no exista, muestra “Lo enviamos” (no revelar existencia de cuenta)
- Indica claramente la expiración y guía al usuario a reemitir si expiró
- Ofrece email en HTML y texto plano (robusto para tecnologías de asistencia y entornos variados)
5. Fundamentos de autorización: centraliza el “puede/no puede” con Policies y Gates
La autorización se rompe cuando se dispersa en if dentro de la UI. En Laravel, organizarse alrededor de estos dos es el enfoque más limpio:
- Policy: permisos para un modelo (p. ej., Post/Project/Order)
- Gate: decisiones sin un modelo (acceso a consola admin, feature flags, etc.)
5.1 Crear una Policy
Ejemplo: definir si un usuario puede editar un proyecto.
php artisan make:policy ProjectPolicy --model=Project
// app/Policies/ProjectPolicy.php
class ProjectPolicy
{
public function view(User $user, Project $project): bool
{
return $user->tenant_id === $project->tenant_id;
}
public function update(User $user, Project $project): bool
{
if ($user->tenant_id !== $project->tenant_id) return false;
return $user->role === 'admin' || $user->role === 'owner';
}
public function delete(User $user, Project $project): bool
{
return $user->tenant_id === $project->tenant_id
&& $user->role === 'owner';
}
}
5.2 Usarla en controladores (un “portero” unificado)
public function edit(Project $project)
{
$this->authorize('update', $project);
return view('projects.edit', compact('project'));
}
Esto mantiene la lógica de autorización en un solo lugar. Las revisiones se vuelven “revisa la Policy”, y los chequeos omitidos bajan drásticamente.
6. Diseño de roles: empieza pequeño, mantén significados explícitos
Demasiados roles se vuelven inmanejables. Un inicio simple de 4 niveles suele ser práctico:
owner: facturación, eliminación, máximo privilegioadmin: configuración y gestión de miembrosmember: operaciones normalesviewer: solo lectura
Lo importante: documenta cada rol y aplícalo en código (Policy). Ocultar botones no es seguridad: valida siempre del lado servidor con authorize.
7. Autenticación de API: usa Sanctum para SPA y clientes externos
A veces quieres APIs más allá de las sesiones del navegador. Sanctum es el valor por defecto más fácil en Laravel.
7.1 Dos estilos de uso
- Auth para SPA (SPA en el mismo dominio)
- Sesión + CSRF, pero accediendo vía API
- Tokens de acceso personal (clientes externos)
- Emitir tokens y autenticar con
Authorization: Bearer
- Emitir tokens y autenticar con
7.2 Ejemplo de emisión de token (clientes externos)
$token = $user->createToken('cli', ['orders:read'])->plainTextToken;
return response()->json(['token' => $token]);
7.3 Proteger rutas
Route::middleware('auth:sanctum')->get('/api/v1/orders', function () {
return OrderResource::collection(Order::latest()->paginate());
});
Los scopes/abilities reducen el radio de impacto si se filtra un token.
8. Rate limiting: protege siempre login/reset/invitaciones
El login y el restablecimiento de contraseña son objetivos comunes de ataque. Considera el rate limiting como obligatorio.
Route::post('/login', [AuthController::class, 'store'])->middleware('throttle:10,1');
- Elige valores realistas (p. ej., máximo 10 por minuto)
- Para 429, guía a los usuarios: “Espera e inténtalo de nuevo.”
- Evita mensajes que revelen si un email existe
9. Formularios de login accesibles: patrón estándar para etiquetas, errores y foco
Las pantallas de auth las visita casi todo el mundo, así que la accesibilidad tiene un ROI alto. Aquí tienes un “formulario estándar” mínimo y efectivo.
9.1 Resumen de errores (validación del servidor)
@if ($errors->any())
<div id="error-summary" role="alert" tabindex="-1" class="border p-3 mb-4">
<h2 class="font-semibold">Please review your input.</h2>
<ul class="list-disc pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
<script>
(function(){
const el = document.getElementById('error-summary');
if (el) el.focus();
})();
</script>
@endif
- Mover el foco al resumen ayuda a usuarios de lectores de pantalla a entender de inmediato qué pasó
- No dependas solo del color rojo: usa explicación en texto
9.2 Vincular inputs con errores
@php $emailError = $errors->first('email'); @endphp
<label for="email" class="block font-medium">
Email <span class="sr-only">Required</span><span aria-hidden="true">(Required)</span>
</label>
<input id="email" name="email" type="email" value="{{ old('email') }}"
autocomplete="email"
aria-invalid="{{ $emailError ? 'true' : 'false' }}"
aria-describedby="{{ $emailError ? 'email-error' : 'email-help' }}"
class="border rounded px-3 py-2 w-full">
<p id="email-help" class="text-sm text-gray-600">Example: hanako@example.com</p>
@if($emailError)
<p id="email-error" class="text-sm text-red-700">{{ $emailError }}</p>
@endif
Haz lo mismo para la contraseña. Una vez que lo conviertas en componentes Blade, todos los formularios serán consistentes y seguros.
10. Humaniza las “pantallas frustrantes” (403/419/503)
Los usuarios suelen confundirse con errores de permisos y expiración de sesión. Pulir esto reduce tickets de soporte y aumenta la confianza.
- 403 (Prohibido)
- Explica brevemente qué está bloqueado y ofrece siguientes pasos (“Volver”, “Solicitar acceso”, etc.)
- 419 (Sesión expirada / CSRF)
- Explica “Tu sesión expiró por inactividad” y ofrece una ruta clara para volver a iniciar sesión
- 503 (Mantenimiento / caída temporal)
- Da un ETA si es posible, alcance del impacto y una alternativa de soporte
Para accesibilidad, usa encabezados claros y texto de enlaces específico. Evita “Haz clic aquí”; usa “Volver al inicio de sesión”, etc.
11. Refleja la autorización en la UI (pero la Policy sigue siendo la defensa final)
Ocultar o mostrar botones “Editar” importa para la experiencia, pero no es seguridad.
- Mostrar:
@canpara UX - Ejecutar:
authorizepara bloquear de verdad
Este enfoque de dos capas es estable.
@can('update', $project)
<a href="{{ route('projects.edit', $project) }}">Edit</a>
@endcan
12. Tests: evita incidentes de permisos en CI
La autorización tiene alto impacto, así que los tests de Feature valen la pena.
12.1 Un usuario distinto no puede actualizar
public function test_user_cannot_update_other_users_project()
{
$t1 = Tenant::factory()->create();
$t2 = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $t1->id, 'role' => 'admin']);
$otherProject = Project::factory()->create(['tenant_id' => $t2->id]);
$this->actingAs($user);
app()->instance('tenant', $t1);
$res = $this->patch("/projects/{$otherProject->id}", ['name' => 'x']);
$res->assertForbidden();
}
12.2 Solo owner puede eliminar
public function test_only_owner_can_delete()
{
$t = Tenant::factory()->create();
$owner = User::factory()->create(['tenant_id'=>$t->id, 'role'=>'owner']);
$admin = User::factory()->create(['tenant_id'=>$t->id, 'role'=>'admin']);
$p = Project::factory()->create(['tenant_id'=>$t->id]);
$this->actingAs($admin);
app()->instance('tenant', $t);
$this->delete("/projects/{$p->id}")->assertForbidden();
$this->actingAs($owner);
$this->delete("/projects/{$p->id}")->assertRedirect();
}
Incluso solo estos dos tests previenen muchos fallos comunes de autorización.
13. Errores comunes y cómo evitarlos
- La autorización varía por pantalla
- Centraliza en Policies y estandariza
authorizeen controladores
- Centraliza en Policies y estandariza
- Sensación de seguridad porque los botones están ocultos
- La defensa final es del lado servidor: siempre
authorize
- La defensa final es del lado servidor: siempre
- Demasiados roles
- Empieza con ~4 roles y expande solo cuando sea necesario
- El texto del reset filtra si un usuario existe
- Usa la misma respuesta independientemente de la existencia
- Pantallas 419/403 poco útiles
- Siempre ofrece siguientes pasos (re-login, volver, solicitar acceso)
- Errores mostrados solo con color
- Usa resúmenes, mensajes de texto y enlaces
aria-*
- Usa resúmenes, mensajes de texto y enlaces
14. Resumen: cuando auth se vuelve un “sistema”, tu app crece sin romperse
Con Breeze y Sanctum puedes construir rápidamente una base segura de autenticación. Al superponer Policies/Gates para unificar “qué está permitido” en el código, reduces incidentes de permisos incluso cuando se multiplican las pantallas. Y al hacer que los formularios de login y la UI de errores sean accesibles por defecto, reduces fricción de usuarios y carga de soporte, facilitando desarrollo y operaciones.
Si estandarizas pronto estas cuatro cosas:
- Autenticación (login/reset)
- Autorización (centralizar en Policy, aplicar
authorize) - Rate limiting (proteger flujos de auth)
- Formularios accesibles (labels/errores/foco)
…tu equipo obtiene una base que permite crecer en funcionalidades con confianza.
