Skip to content

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:

EstrategiaCómo funcionaCuándo
JWT (por defecto)La sesión vive cifrada en una cookie; no requiere base de datosCredenciales, proyectos simples, edge
Base de datosLa 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.local
env
# .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 } = handlers
ts
// 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.com o AUTH_TRUST_HOST=true detrá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 ​

  1. Agrega el proveedor de Google (next-auth/providers/google) y un botón "Continuar con Google" en el login.
  2. Crea la página /admin que sólo vea un usuario con rol admin (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/google
ts
// 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.ts configura proveedores (GitHub, Credentials...), callbacks y páginas; exporta auth, signIn, signOut y handlers.
  • app/api/auth/[...nextauth]/route.ts expone los endpoints; middleware.ts (o proxy.ts en Next 16) redirige rutas privadas.
  • Lee la sesión con await auth() en el servidor o useSession() en el cliente.
  • Verifica sesión y permisos en cada página privada, Server Action y Route Handler.

Academia Web — aprende, copia y construye.