Tema
Autenticación con Auth.js
Paneles de administración, áreas de clientes, cursos privados: casi todo proyecto real necesita usuarios. Auth.js (antes NextAuth.js) es la librería de autenticación más usada con Next.js. Aprenderás a configurar inicio de sesión con GitHub y con email y contraseña, proteger páginas, Server Actions y Route Handlers, y manejar roles.
Concepto
Auth.js se encarga de las partes difíciles y peligrosas de la autenticación:
- Flujos OAuth con proveedores (Google, GitHub, Microsoft, Facebook...).
- Sesiones seguras en cookies cifradas (httpOnly, secure, sameSite).
- Protección CSRF y rotación de tokens.
Dos estrategias de sesión:
| Estrategia | Cómo funciona | Cuándo |
|---|---|---|
| JWT (por defecto) | La sesión vive cifrada en una cookie; no requiere base de datos | Credenciales, proyectos simples, edge |
| Base de datos | La cookie guarda un id; la sesión vive en la BD (con un adapter, p. ej. Prisma) | Revocar sesiones, ver dispositivos activos |
Analogía: con JWT, el usuario lleva su credencial sellada en el bolsillo; con base de datos, lleva un número de folio y tú consultas el expediente.
Alternativas
Auth.js es open source y gratuito. Otras opciones populares: Better Auth (moderna, muy completa, el equipo de Auth.js se unió a ella en 2025), Clerk (servicio administrado con interfaz lista) y Supabase Auth. Los conceptos de esta lección (sesión, proteger rutas, autorizar acciones) aplican a todas.
Instalación
bash
npm i next-auth@beta bcryptjs zod
npm i -D @types/bcryptjs
npx auth secret # genera AUTH_SECRET en .env.localenv
# .env.local
AUTH_SECRET="valor-generado-por-npx-auth-secret"
# OAuth de GitHub: github.com/settings/developers → New OAuth App
# Homepage URL: http://localhost:3000
# Callback URL: http://localhost:3000/api/auth/callback/github
AUTH_GITHUB_ID="tu-client-id"
AUTH_GITHUB_SECRET="tu-client-secret"Configuración
ts
import NextAuth, { CredentialsSignin } from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Credentials from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { buscarUsuarioPorEmail } from '@/lib/usuarios'
const loginSchema = z.object({
email: z.email(),
password: z.string().min(1)
})
class CredencialesInvalidas extends CredentialsSignin {
code = 'credenciales_invalidas'
}
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
GitHub, // lee AUTH_GITHUB_ID y AUTH_GITHUB_SECRET automáticamente
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Contraseña', type: 'password' }
},
async authorize(credenciales) {
const r = loginSchema.safeParse(credenciales)
if (!r.success) throw new CredencialesInvalidas()
const usuario = await buscarUsuarioPorEmail(r.data.email.toLowerCase())
if (!usuario?.passwordHash) throw new CredencialesInvalidas()
const ok = await bcrypt.compare(r.data.password, usuario.passwordHash)
if (!ok) throw new CredencialesInvalidas()
// Lo que devuelvas aquí se guarda en el token (nunca el hash)
return { id: usuario.id, name: usuario.nombre, email: usuario.email, rol: usuario.rol }
}
})
],
session: { strategy: 'jwt', maxAge: 60 * 60 * 24 * 7 }, // 7 días
pages: { signIn: '/login' }, // nuestra propia página de login
callbacks: {
// Se ejecuta al crear/actualizar el token: agregamos id y rol
jwt({ token, user }) {
if (user) {
token.id = user.id
token.rol = (user as { rol?: string }).rol ?? 'cliente'
}
return token
},
// Lo que expone auth() / useSession()
session({ session, token }) {
session.user.id = token.id as string
session.user.rol = token.rol as string
return session
},
// Se usa en el middleware/proxy: ¿puede ver esta ruta?
authorized({ auth, request }) {
const { pathname } = request.nextUrl
const autenticado = Boolean(auth?.user)
if (pathname.startsWith('/admin')) return auth?.user?.rol === 'admin'
if (pathname.startsWith('/cuenta')) return autenticado
return true
}
}
})ts
// Endpoints de Auth.js: /api/auth/signin, /api/auth/callback/github, etc.
import { handlers } from '@/auth'
export const { GET, POST } = handlersts
// Agrega id y rol a los tipos de la sesión
import type { DefaultSession } from 'next-auth'
declare module 'next-auth' {
interface Session {
user: { id: string; rol: string } & DefaultSession['user']
}
}ts
import 'server-only'
import bcrypt from 'bcryptjs'
// Ejemplo en memoria. En la lección 12 usarás Prisma: prisma.usuario.findUnique(...)
type Usuario = { id: string; nombre: string; email: string; passwordHash: string; rol: 'cliente' | 'admin' }
const usuarios: Usuario[] = [
{
id: '1',
nombre: 'Ana Admin',
email: 'admin@demo.com',
passwordHash: bcrypt.hashSync('Admin1234', 10),
rol: 'admin'
}
]
export async function buscarUsuarioPorEmail(email: string) {
return usuarios.find((u) => u.email === email) ?? null
}
export async function crearUsuario(datos: { nombre: string; email: string; password: string }) {
const usuario: Usuario = {
id: crypto.randomUUID(),
nombre: datos.nombre,
email: datos.email.toLowerCase(),
passwordHash: await bcrypt.hash(datos.password, 12),
rol: 'cliente'
}
usuarios.push(usuario)
return usuario
}Proteger rutas con middleware / proxy
Crea este archivo en la raíz del proyecto (junto a app/). Se ejecuta antes de cada petición y usa el callback authorized:
ts
export { auth as middleware } from '@/auth'
export const config = {
// No ejecutar en archivos estáticos ni en las rutas internas de Next
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
}ts
// En Next.js 16, middleware.ts se renombró a proxy.ts
export { auth as proxy } from '@/auth'
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
}Si authorized devuelve false, Auth.js redirige a /login.
El middleware no es suficiente
El middleware es una primera barrera para redirigir rápido, pero siempre verifica la sesión también donde lees o modificas datos (páginas, Server Actions, Route Handlers). Es la práctica recomendada por el propio equipo de Next.js.
Páginas de login y registro
ts
'use server'
import { AuthError } from 'next-auth'
import { z } from 'zod'
import { signIn, signOut } from '@/auth'
import { buscarUsuarioPorEmail, crearUsuario } from '@/lib/usuarios'
export type EstadoAuth = { error?: string }
export async function iniciarSesion(_: EstadoAuth, formData: FormData): Promise<EstadoAuth> {
try {
await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirectTo: String(formData.get('callbackUrl') || '/cuenta')
})
return {}
} catch (error) {
if (error instanceof AuthError) {
return { error: 'Email o contraseña incorrectos' }
}
throw error // ¡importante! deja pasar el redirect de Next
}
}
const registroSchema = z.object({
nombre: z.string().trim().min(2, 'Escribe tu nombre'),
email: z.email('Email inválido'),
password: z.string().min(8, 'Mínimo 8 caracteres').regex(/[0-9]/, 'Incluye un número')
})
export async function registrarse(_: EstadoAuth, formData: FormData): Promise<EstadoAuth> {
const r = registroSchema.safeParse(Object.fromEntries(formData))
if (!r.success) return { error: r.error.issues[0].message }
if (await buscarUsuarioPorEmail(r.data.email.toLowerCase())) {
return { error: 'Ese email ya está registrado' }
}
await crearUsuario(r.data)
// Inicia sesión automáticamente y redirige
await signIn('credentials', { email: r.data.email, password: r.data.password, redirectTo: '/cuenta' })
return {}
}
export async function cerrarSesion() {
await signOut({ redirectTo: '/' })
}
export async function entrarConGitHub() {
await signIn('github', { redirectTo: '/cuenta' })
}tsx
import Link from 'next/link'
import FormLogin from './FormLogin'
import { entrarConGitHub } from '../acciones'
export const metadata = { title: 'Iniciar sesión' }
type Props = { searchParams: Promise<{ callbackUrl?: string }> }
export default async function Login({ searchParams }: Props) {
const { callbackUrl } = await searchParams
return (
<main className="mx-auto mt-16 max-w-sm space-y-6 rounded-2xl border p-8">
<h1 className="text-2xl font-bold">Iniciar sesión</h1>
<form action={entrarConGitHub}>
<button className="w-full rounded-lg bg-gray-900 py-2 text-white">Continuar con GitHub</button>
</form>
<div className="text-center text-sm text-gray-500">o con tu email</div>
<FormLogin callbackUrl={callbackUrl} />
<p className="text-center text-sm">
¿No tienes cuenta? <Link href="/registro" className="text-violet-700 underline">Regístrate</Link>
</p>
</main>
)
}tsx
'use client'
import { useActionState } from 'react'
import { iniciarSesion, type EstadoAuth } from '../acciones'
export default function FormLogin({ callbackUrl }: { callbackUrl?: string }) {
const [estado, accion, pendiente] = useActionState<EstadoAuth, FormData>(iniciarSesion, {})
return (
<form action={accion} className="space-y-3">
<input type="hidden" name="callbackUrl" value={callbackUrl ?? ''} />
<input name="email" type="email" placeholder="Email" required autoComplete="email" className="w-full rounded border px-3 py-2" />
<input name="password" type="password" placeholder="Contraseña" required autoComplete="current-password" className="w-full rounded border px-3 py-2" />
{estado.error && <p role="alert" className="text-sm text-red-600">{estado.error}</p>}
<button disabled={pendiente} className="w-full rounded-lg bg-violet-600 py-2 text-white disabled:opacity-60">
{pendiente ? 'Entrando…' : 'Entrar'}
</button>
</form>
)
}tsx
'use client'
import { useActionState } from 'react'
import { registrarse, type EstadoAuth } from '../acciones'
export default function Registro() {
const [estado, accion, pendiente] = useActionState<EstadoAuth, FormData>(registrarse, {})
return (
<main className="mx-auto mt-16 max-w-sm rounded-2xl border p-8">
<h1 className="mb-6 text-2xl font-bold">Crear cuenta</h1>
<form action={accion} className="space-y-3">
<input name="nombre" placeholder="Nombre" required className="w-full rounded border px-3 py-2" />
<input name="email" type="email" placeholder="Email" required className="w-full rounded border px-3 py-2" />
<input name="password" type="password" placeholder="Contraseña" required autoComplete="new-password" className="w-full rounded border px-3 py-2" />
{estado.error && <p role="alert" className="text-sm text-red-600">{estado.error}</p>}
<button disabled={pendiente} className="w-full rounded-lg bg-violet-600 py-2 text-white">
{pendiente ? 'Creando…' : 'Crear cuenta'}
</button>
</form>
</main>
)
}Leer la sesión
En Server Components
tsx
// app/cuenta/page.tsx
import { redirect } from 'next/navigation'
import { auth } from '@/auth'
import { cerrarSesion } from '../(auth)/acciones'
export default async function Cuenta() {
const sesion = await auth()
if (!sesion?.user) redirect('/login?callbackUrl=/cuenta') // segunda barrera
return (
<main className="mx-auto max-w-xl p-8">
<h1 className="text-2xl font-bold">Hola, {sesion.user.name} 👋</h1>
<p className="text-gray-600">{sesion.user.email} · rol: {sesion.user.rol}</p>
<form action={cerrarSesion} className="mt-6">
<button className="rounded border px-4 py-2">Cerrar sesión</button>
</form>
</main>
)
}En el header (Server Component)
tsx
// components/MenuUsuario.tsx
import Link from 'next/link'
import { auth } from '@/auth'
export default async function MenuUsuario() {
const sesion = await auth()
if (!sesion?.user) return <Link href="/login">Iniciar sesión</Link>
return (
<div className="flex items-center gap-3">
{sesion.user.rol === 'admin' && <Link href="/admin">Admin</Link>}
<Link href="/cuenta">{sesion.user.name}</Link>
</div>
)
}En Client Components
Envuelve la app con SessionProvider y usa useSession:
tsx
// components/Providers.tsx
'use client'
import { SessionProvider } from 'next-auth/react'
export default function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}tsx
'use client'
import { useSession } from 'next-auth/react'
export function SaludoCliente() {
const { data: sesion, status } = useSession()
if (status === 'loading') return <span>…</span>
return <span>{sesion?.user ? `Hola, ${sesion.user.name}` : 'Invitado'}</span>
}Prefiere leer la sesión en el servidor siempre que puedas: es más rápido y no muestra parpadeos.
Proteger Server Actions y Route Handlers
Crea ayudantes reutilizables:
ts
// lib/autorizacion.ts
import 'server-only'
import { auth } from '@/auth'
export async function requerirUsuario() {
const sesion = await auth()
if (!sesion?.user) throw new Error('No autenticado')
return sesion.user
}
export async function requerirAdmin() {
const usuario = await requerirUsuario()
if (usuario.rol !== 'admin') throw new Error('No autorizado')
return usuario
}ts
// app/admin/acciones.ts
'use server'
import { revalidatePath } from 'next/cache'
import { requerirAdmin } from '@/lib/autorizacion'
export async function eliminarProducto(id: number) {
await requerirAdmin() // lanza error si no es admin
// await prisma.producto.delete({ where: { id } })
revalidatePath('/admin/productos')
}ts
// app/api/mis-pedidos/route.ts
import { auth } from '@/auth'
export async function GET() {
const sesion = await auth()
if (!sesion?.user) return Response.json({ error: 'No autenticado' }, { status: 401 })
// Filtra SIEMPRE por el usuario de la sesión, nunca por un id que venga del cliente
// const pedidos = await prisma.pedido.findMany({ where: { usuarioId: sesion.user.id } })
return Response.json({ pedidos: [] })
}Producción
- Configura
AUTH_SECRET(distinto al de desarrollo) y las credenciales OAuth de producción en tu plataforma. - En GitHub/Google, registra la URL de callback de producción:
https://tudominio.com/api/auth/callback/github. - Si no despliegas en Vercel, define
AUTH_URL=https://tudominio.comoAUTH_TRUST_HOST=truedetrás de un proxy (Nginx).
Buena práctica
- Para la mayoría de los clientes, ofrece al menos un proveedor social (Google) además de email: reduce la fricción del registro.
- Con credenciales, agrega límite de intentos (rate limiting) y verificación de email.
- Guarda en la sesión sólo lo necesario (id, rol). Consulta el resto en la base de datos cuando lo necesites.
Errores graves
- Confiar sólo en el middleware o en ocultar botones: verifica la sesión en cada acción que modifique datos.
- Usar el id de usuario que manda el cliente (un campo oculto del formulario) en lugar del de la sesión.
- Guardar contraseñas sin hash. Usa bcrypt o argon2 (ver Autenticación en Node).
Ejercicios
- Agrega el proveedor de Google (
next-auth/providers/google) y un botón "Continuar con Google" en el login. - Crea la página
/adminque sólo vea un usuario con roladmin(verifica en el middleware y en la página) y muestre un mensaje de bienvenida.
Ver solución
ts
// 1. auth.ts
import Google from 'next-auth/providers/google'
// providers: [GitHub, Google, Credentials({...})]
// .env.local
// AUTH_GOOGLE_ID="..." (console.cloud.google.com → Credenciales → ID de cliente OAuth)
// AUTH_GOOGLE_SECRET="..."
// URI de redirección: http://localhost:3000/api/auth/callback/googlets
// app/(auth)/acciones.ts
export async function entrarConGoogle() {
await signIn('google', { redirectTo: '/cuenta' })
}tsx
// app/(auth)/login/page.tsx (agrega)
<form action={entrarConGoogle}>
<button className="w-full rounded-lg border py-2">Continuar con Google</button>
</form>tsx
// 2. app/admin/page.tsx
import { redirect } from 'next/navigation'
import { auth } from '@/auth'
export const metadata = { title: 'Administración', robots: { index: false } }
export default async function Admin() {
const sesion = await auth()
if (!sesion?.user) redirect('/login?callbackUrl=/admin')
if (sesion.user.rol !== 'admin') redirect('/')
return (
<main className="p-8">
<h1 className="text-2xl font-bold">Panel de administración</h1>
<p>Bienvenida, {sesion.user.name}.</p>
</main>
)
}
// El callback authorized de auth.ts ya bloquea /admin a quien no sea admin (primera barrera).Resumen
auth.tsconfigura proveedores (GitHub, Credentials...), callbacks y páginas; exportaauth,signIn,signOutyhandlers.app/api/auth/[...nextauth]/route.tsexpone los endpoints;middleware.ts(oproxy.tsen Next 16) redirige rutas privadas.- Lee la sesión con
await auth()en el servidor ouseSession()en el cliente. - Verifica sesión y permisos en cada página privada, Server Action y Route Handler.