Desarrollo Web

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.

Equipo Nexgen
10 min de lectura
#Next.js#NextAuth#Authentication#JWT#OAuth#Security
Autenticación Segura en Next.js con NextAuth.js y JWT

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.