Autenticación Segura en Next.js con NextAuth.js y JWT
Guía completa de autenticación en Next.js 16 con NextAuth.js. OAuth, JWT, sesiones, roles y mejores prácticas de seguridad.

Respuesta Directa
NextAuth.js es la solución oficial para autenticación en Next.js. Setup completo en 4 pasos: (1) Instalar NextAuth, (2) Configurar API route /api/auth/[...nextauth], (3) Agregar providers (Credentials, Google, GitHub), (4) Proteger páginas con middleware. Tiempo: ~30 minutos para implementación básica, ~2 horas para producción completa.
¿Por Qué NextAuth.js?
Alternativas y Comparación
| Solución | Complejidad | Features | Costo | Mejor para | | -------------------- | ----------- | ------------------ | ------ | ------------------------------------------------- | | NextAuth.js | Media | ✅ Completas | Gratis | Apps de cualquier tamaño | | Auth0 | Baja | ✅✅ Muy completas | $$$ | Enterprise, compliance estricto | | Clerk | Muy baja | ✅ Buenas | $$ | MVPs rápidos, startups | | Firebase Auth | Baja | ✅ Buenas | $ | Apps Firebase | | DIY (JWT manual) | Alta | ⚠️ Básicas | Gratis | Solo si realmente necesitas customización extrema |
NextAuth.js gana por: Balance entre flexibilidad, features y ser open-source.
Setup Inicial
Instalación
npm install next-auth@latest
Variables de Entorno
# .env.local
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=tu-secret-super-seguro-aqui
# Para providers OAuth
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
Generar NEXTAUTH_SECRET:
openssl rand -base64 32
Configuración Base
API Route Handler
// app/api/auth/[...nextauth]/route.ts
import NextAuth, { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import GitHubProvider from "next-auth/providers/github";
import CredentialsProvider from "next-auth/providers/credentials";
import { compare } from "bcrypt";
import { db } from "@/lib/database";
export const authOptions: NextAuthOptions = {
providers: [
// Provider 1: Google OAuth
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
// Provider 2: GitHub OAuth
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// Provider 3: Credentials (email/password)
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error("Email y password requeridos");
}
// Buscar usuario en DB
const user = await db.user.findUnique({
where: { email: credentials.email },
});
if (!user || !user.hashedPassword) {
throw new Error("Credenciales inválidas");
}
// Verificar password
const isValidPassword = await compare(
credentials.password,
user.hashedPassword
);
if (!isValidPassword) {
throw new Error("Credenciales inválidas");
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
// Configuración de sesión
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 días
},
// Páginas personalizadas
pages: {
signIn: "/login",
signOut: "/",
error: "/login",
verifyRequest: "/verify-request",
},
// Callbacks
callbacks: {
async jwt({ token, user, account }) {
// Agregar datos custom al token
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
// Agregar datos custom a la sesión
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
// Security
secret: process.env.NEXTAUTH_SECRET,
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
Session Provider
// app/providers.tsx
"use client";
import { SessionProvider } from "next-auth/react";
export function Providers({ children }: { children: React.Node }) {
return <SessionProvider>{children}</SessionProvider>;
}
// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.Node }) {
return (
<html lang="es">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Páginas de Login
Página de Login
// app/login/page.tsx
"use client";
import { signIn } from "next-auth/react";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string;
const password = formData.get("password") as string;
try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
setError("Credenciales inválidas");
return;
}
router.push("/dashboard");
router.refresh();
} catch (error) {
setError("Error al iniciar sesión");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
<h2 className="text-3xl font-bold text-center">Iniciar Sesión</h2>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
{/* Login con Email/Password */}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full px-3 py-2 border rounded-md"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 block w-full px-3 py-2 border rounded-md"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Cargando..." : "Iniciar Sesión"}
</button>
</form>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">O continuar con</span>
</div>
</div>
{/* OAuth Providers */}
<div className="space-y-3">
<button
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
className="w-full flex items-center justify-center gap-2 bg-white border border-gray-300 py-2 rounded-md hover:bg-gray-50"
>
<img src="/icons/google.svg" alt="Google" className="w-5 h-5" />
Google
</button>
<button
onClick={() => signIn("github", { callbackUrl: "/dashboard" })}
className="w-full flex items-center justify-center gap-2 bg-gray-900 text-white py-2 rounded-md hover:bg-gray-800"
>
<img src="/icons/github.svg" alt="GitHub" className="w-5 h-5" />
GitHub
</button>
</div>
</div>
</div>
);
}
Proteger Páginas (Server Components)
Verificar Sesión en Server Component
// app/dashboard/page.tsx
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/login");
}
return (
<div>
<h1>Dashboard</h1>
<p>Bienvenido, {session.user?.name}!</p>
<p>Email: {session.user?.email}</p>
</div>
);
}
Proteger Rutas con Middleware
// middleware.ts
import { withAuth } from "next-auth/middleware";
export default withAuth({
callbacks: {
authorized: ({ req, token }) => {
// Verificar si hay token (usuario autenticado)
if (!token) return false;
// Rutas admin requieren role "admin"
if (req.nextUrl.pathname.startsWith("/admin")) {
return token.role === "admin";
}
return true;
},
},
pages: {
signIn: "/login",
},
});
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*", "/profile/:path*"],
};
Proteger Páginas (Client Components)
// app/profile/page.tsx
"use client";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function ProfilePage() {
const { data: session, status } = useSession();
const router = useRouter();
useEffect(() => {
if (status === "unauthenticated") {
router.push("/login");
}
}, [status, router]);
if (status === "loading") {
return <div>Cargando...</div>;
}
if (!session) {
return null;
}
return (
<div>
<h1>Mi Perfil</h1>
<p>Nombre: {session.user?.name}</p>
<p>Email: {session.user?.email}</p>
<p>Role: {session.user?.role}</p>
</div>
);
}
Control de Acceso Basado en Roles (RBAC)
Tipos TypeScript
// types/next-auth.d.ts
import { DefaultSession, DefaultUser } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: "user" | "admin" | "moderator";
} & DefaultSession["user"];
}
interface User extends DefaultUser {
role: "user" | "admin" | "moderator";
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
role: "user" | "admin" | "moderator";
}
}
Hook Custom para Roles
// hooks/useRequireRole.ts
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
type Role = "user" | "admin" | "moderator";
export function useRequireRole(allowedRoles: Role[]) {
const { data: session, status } = useSession();
const router = useRouter();
useEffect(() => {
if (status === "loading") return;
if (!session) {
router.push("/login");
return;
}
if (!allowedRoles.includes(session.user.role)) {
router.push("/unauthorized");
}
}, [session, status, router, allowedRoles]);
return { session, status };
}
// Uso
export default function AdminPage() {
useRequireRole(["admin"]);
return <div>Panel de Administración</div>;
}
Componente de Autorización
// components/auth/RequireAuth.tsx
"use client";
import { useSession } from "next-auth/react";
import { redirect } from "next/navigation";
type Role = "user" | "admin" | "moderator";
interface RequireAuthProps {
children: React.ReactNode;
allowedRoles?: Role[];
fallback?: React.ReactNode;
}
export function RequireAuth({
children,
allowedRoles,
fallback,
}: RequireAuthProps) {
const { data: session, status } = useSession();
if (status === "loading") {
return fallback || <div>Cargando...</div>;
}
if (!session) {
redirect("/login");
}
if (allowedRoles && !allowedRoles.includes(session.user.role)) {
return <div>No tienes permisos para ver esta página</div>;
}
return <>{children}</>;
}
// Uso
<RequireAuth allowedRoles={["admin"]}>
<AdminDashboard />
</RequireAuth>;
API Routes Protegidas
// app/api/users/route.ts
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { NextResponse } from "next/server";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
}
// Verificar role
if (session.user.role !== "admin") {
return NextResponse.json(
{ error: "Permisos insuficientes" },
{ status: 403 }
);
}
// Lógica de la API
const users = await db.user.findMany();
return NextResponse.json({ users });
}
Registro de Usuarios
// app/api/register/route.ts
import { hash } from "bcrypt";
import { db } from "@/lib/database";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
try {
const { name, email, password } = await req.json();
// Validar
if (!name || !email || !password) {
return NextResponse.json(
{ error: "Todos los campos son requeridos" },
{ status: 400 }
);
}
// Verificar si el usuario ya existe
const existingUser = await db.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: "El email ya está registrado" },
{ status: 400 }
);
}
// Hash del password
const hashedPassword = await hash(password, 12);
// Crear usuario
const user = await db.user.create({
data: {
name,
email,
hashedPassword,
role: "user",
},
});
return NextResponse.json(
{
user: {
id: user.id,
name: user.name,
email: user.email,
},
},
{ status: 201 }
);
} catch (error) {
return NextResponse.json(
{ error: "Error al crear usuario" },
{ status: 500 }
);
}
}
Mejores Prácticas de Seguridad
1. HTTPS en Producción
// next.config.ts
const nextConfig = {
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
],
},
];
},
};
2. CSRF Protection
NextAuth.js incluye CSRF protection por defecto, pero asegúrate:
// authOptions
export const authOptions: NextAuthOptions = {
// ...
cookies: {
csrfToken: {
name: `__Host-next-auth.csrf-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
},
},
},
};
3. Rate Limiting
// lib/rate-limit.ts
import { LRUCache } from "lru-cache";
const rateLimit = new LRUCache({
max: 500,
ttl: 60000, // 1 minuto
});
export function checkRateLimit(ip: string): boolean {
const tokenCount = (rateLimit.get(ip) as number) || 0;
if (tokenCount > 5) {
return false; // Rate limit excedido
}
rateLimit.set(ip, tokenCount + 1);
return true;
}
// Uso en API route
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") || "unknown";
if (!checkRateLimit(ip)) {
return NextResponse.json({ error: "Demasiados intentos" }, { status: 429 });
}
// Resto de la lógica...
}
4. Validación de Input
import { z } from "zod";
const loginSchema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(8, "Password debe tener mínimo 8 caracteres"),
});
// En el login handler
try {
const validated = loginSchema.parse({ email, password });
// Proceder con login...
} catch (error) {
if (error instanceof z.ZodError) {
return { error: error.errors[0].message };
}
}
Troubleshooting
Error: "Session is undefined"
// ✅ Solución: Asegurar que SessionProvider envuelve la app
// app/layout.tsx
<SessionProvider>{children}</SessionProvider>
Error: "NEXTAUTH_URL not configured"
# .env.local
NEXTAUTH_URL=http://localhost:3000 # Desarrollo
NEXTAUTH_URL=https://tudominio.com # Producción
OAuth no funciona en desarrollo
# Google/GitHub OAuth requieren redirect URI exacto
# En desarrollo usa:
http://localhost:3000/api/auth/callback/google
Conclusión
Has aprendido:
✅ Setup completo de NextAuth.js ✅ Múltiples providers (Credentials, OAuth) ✅ Protección de rutas server y client ✅ RBAC con roles y permisos ✅ Mejores prácticas de seguridad
En Nexgen, usamos NextAuth.js en 90% de nuestros proyectos Next.js por su flexibilidad y robustez. Para casos enterprise con SSO, complementamos con Auth0.
Recursos Adicionales
¿Necesitas ayuda con autenticación en tu app? Contáctanos.
Artículos Relacionados

Next.js vs. Remix: Comparativa Completa 2025 - ¿Cuál Elegir?
Análisis detallado de Next.js y Remix en 2025. Comparamos rendimiento, DX, ecosistema y casos de uso para ayudarte a elegir el framework correcto.
Por Equipo Nexgen

React Server Components con Next.js 16: El Futuro de las Web Apps
Descubre cómo los Server Components revolucionan el desarrollo de aplicaciones web. Código paso a paso, benchmarks de rendimiento y mejores prácticas.
Por Equipo Nexgen

Cómo Construir tu Primer Agente de IA con LangChain en 5 Pasos
Tutorial paso a paso para construir un agente de IA funcional usando LangChain y Python. Desde setup hasta implementación de herramientas.
Por Equipo Nexgen