Saltar al contenido principal

Cómo Implementar Autenticación con Auth.js (NextAuth) Sin Volverte Loco

Guía paso a paso para implementar auth real con Auth.js v5 en Next.js App Router. Google OAuth, credenciales, protección de rutas, sesiones y los errores que nadie te cuenta.

Fran Cobos 8 min de lectura 1446 palabras

Tabla de contenidos

La autenticación es esa feature que parece fácil hasta que empiezas a implementarla. “Son solo un login y un register”, pensé la primera vez. 3 días después estaba debuggeando callbacks de OAuth, tokens expirados, y sesiones que desaparecían en producción.

Auth.js (antes NextAuth) es la mejor opción gratuita para auth en Next.js, pero la documentación asume que ya sabes lo que estás haciendo. Esta guía es la que me habría gustado tener.

Setup inicial (5 minutos)

1. Instalar

npm install next-auth@beta

En 2026, la v5 sigue en @beta pero es estable para producción. No te asustes por el tag.

2. Generar el secret

npx auth secret

Esto genera un AUTH_SECRET en tu .env.local. Este secret firma los JWT y las cookies de sesión. Sin él, nada funciona.

3. Crear la configuración central

// src/lib/auth.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(db),
  session: { strategy: 'jwt' }, // JWT en vez de sesiones en BD
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
    Credentials({
      credentials: {
        email: { type: 'email' },
        password: { type: 'password' },
      },
      authorize: async (credentials) => {
        const user = await db.user.findUnique({
          where: { email: credentials.email as string },
        });

        if (!user || !user.hashedPassword) return null;

        const isValid = await bcrypt.compare(
          credentials.password as string,
          user.hashedPassword
        );

        if (!isValid) return null;

        return { id: user.id, email: user.email, name: user.name };
      },
    }),
  ],
  callbacks: {
    jwt({ token, user }) {
      if (user) {
        token.id = user.id;
      }
      return token;
    },
    session({ session, token }) {
      session.user.id = token.id as string;
      return session;
    },
  },
  pages: {
    signIn: '/login', // Tu página de login custom
  },
});

4. Crear la API route

// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;

5. El middleware para proteger rutas

// src/middleware.ts
import { auth } from '@/lib/auth';

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard');
  const isOnAuth = req.nextUrl.pathname.startsWith('/login') ||
                   req.nextUrl.pathname.startsWith('/register');

  if (isOnDashboard && !isLoggedIn) {
    return Response.redirect(new URL('/login', req.nextUrl));
  }

  if (isOnAuth && isLoggedIn) {
    return Response.redirect(new URL('/dashboard', req.nextUrl));
  }
});

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Los errores que vas a tener (y cómo solucionarlos)

Error 1: “OAUTH_CALLBACK_ERROR” — el más común

[auth][error] OAuthCallbackError: ...

Causa: El redirect URI en la consola de Google no coincide con tu URL real.

Solución: Ve a Google Cloud Console → Credentials → OAuth Client → Authorized redirect URIs y añade:

http://localhost:3000/api/auth/callback/google     # Para dev
https://tudominio.com/api/auth/callback/google     # Para producción

El formato siempre es: {tu-url}/api/auth/callback/{provider}. Me pasé 2 horas la primera vez porque puse /auth/callback/google sin el /api.

Error 2: La sesión desaparece al refrescar

Causa: Si usas strategy: 'database' (el default con un adapter), Auth.js guarda las sesiones en la BD. Pero si el adapter no está bien configurado, la sesión no se persiste.

Solución: Usa strategy: 'jwt' (como en mi config arriba). Los JWT van en una cookie httpOnly y no necesitan BD para las sesiones. Es más simple y funciona siempre.

session: { strategy: 'jwt' },

Error 3: El user.id no está en la sesión

const session = await auth();
console.log(session.user.id); // undefined 😱

Causa: Auth.js no incluye el id del usuario en la sesión por defecto (por seguridad).

Solución: Añadir los callbacks jwt y session como en mi config arriba. Y extender los tipos:

// src/types/next-auth.d.ts
import { DefaultSession } from 'next-auth';

declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
    } & DefaultSession['user'];
  }
}

Error 4: Credentials provider + Adapter = sesiones rotas

Causa: Auth.js no persiste sesiones en BD para el Credentials provider (es una decisión de diseño — los passwords no se consideran “seguros” para sesiones de larga duración).

Solución: Con Credentials, siempre usa strategy: 'jwt'. Si usas solo OAuth (Google, GitHub), puedes usar strategy: 'database'.

Login con Google — Componente completo

// features/auth/components/LoginForm.tsx
'use client';
import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleCredentialsLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError('');

    const result = await signIn('credentials', {
      email,
      password,
      redirect: false,
    });

    if (result?.error) {
      setError('Email o contraseña incorrectos');
      setLoading(false);
      return;
    }

    router.push('/dashboard');
  };

  return (
    <div className="mx-auto max-w-sm space-y-6">
      {/* Google OAuth */}
      <button
        onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
        className="flex w-full items-center justify-center gap-3 rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-50"
      >
        <svg className="h-5 w-5" viewBox="0 0 24 24">
          {/* Google icon SVG path */}
        </svg>
        Continuar con Google
      </button>

      <div className="relative">
        <div className="absolute inset-0 flex items-center">
          <div className="w-full border-t border-gray-200" />
        </div>
        <div className="relative flex justify-center text-sm">
          <span className="bg-white px-4 text-gray-500">o</span>
        </div>
      </div>

      {/* Credentials */}
      <form onSubmit={handleCredentialsLogin} className="space-y-4">
        {error && (
          <p className="rounded-lg bg-red-50 p-3 text-sm text-red-600">
            {error}
          </p>
        )}
        <input
          type="email"
          placeholder="tu@email.com"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
          className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm"
        />
        <input
          type="password"
          placeholder="Contraseña"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
          className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm"
        />
        <button
          type="submit"
          disabled={loading}
          className="w-full rounded-lg bg-blue-600 py-3 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50"
        >
          {loading ? 'Entrando...' : 'Iniciar sesión'}
        </button>
      </form>
    </div>
  );
}

Detalle importante: redirect: false en el signIn de credentials permite manejar el error en el cliente en vez de redirigir a una página de error genérica. Para OAuth (Google), usamos callbackUrl porque el flujo OAuth necesita la redirección.

Obtener la sesión en Server Components

// En cualquier Server Component
import { auth } from '@/lib/auth';

export default async function DashboardPage() {
  const session = await auth();

  if (!session) {
    redirect('/login'); // Por si acaso (el middleware ya lo cubre)
  }

  return (
    <div>
      <h1>Hola, {session.user.name}</h1>
      <p>Tu ID: {session.user.id}</p>
    </div>
  );
}

Obtener la sesión en Client Components

'use client';
import { useSession } from 'next-auth/react';

export function UserMenu() {
  const { data: session, status } = useSession();

  if (status === 'loading') return <Skeleton className="h-8 w-8 rounded-full" />;
  if (!session) return null;

  return (
    <div>
      <img src={session.user.image} alt="" className="h-8 w-8 rounded-full" />
      <span>{session.user.name}</span>
    </div>
  );
}

No olvides el SessionProvider en tu layout:

// app/layout.tsx
import { SessionProvider } from 'next-auth/react';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <SessionProvider>{children}</SessionProvider>
      </body>
    </html>
  );
}

Proteger API Routes

// app/api/tasks/route.ts
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';

export async function GET() {
  const session = await auth();

  if (!session) {
    return NextResponse.json({ error: 'No autorizado' }, { status: 401 });
  }

  const tasks = await db.task.findMany({
    where: { userId: session.user.id },
  });

  return NextResponse.json(tasks);
}

El esquema de Prisma que uso

// prisma/schema.prisma
model User {
  id             String    @id @default(cuid())
  name           String?
  email          String    @unique
  emailVerified  DateTime?
  image          String?
  hashedPassword String?   // null si se registró con OAuth

  accounts Account[]
  sessions Session[]
  tasks    Task[]

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?
  access_token      String?
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

Checklist de seguridad

Antes de ir a producción, verifico:

  • AUTH_SECRET generado con npx auth secret (no un string inventado)
  • Passwords hasheadas con bcrypt (nunca en texto plano)
  • redirect: false en credentials para no filtrar info en la URL
  • Cookies con httpOnly y secure (Auth.js lo hace por defecto en producción)
  • CSRF protection activo (Auth.js lo incluye por defecto)
  • Variables de entorno en .env.local, no hardcodeadas
  • Redirect URIs en la consola de OAuth actualizadas para el dominio de producción
  • Rate limiting en el endpoint de login (Auth.js NO lo incluye — implementar con upstash/ratelimit)

Artículos relacionados

Fran Cobos

Fran Cobos

Desarrollador Full Stack especializado en IA aplicada, automatización y desarrollo web. Escribo sobre herramientas, tutoriales y casos reales para programadores.

¿Necesitas desarrollo a medida?

Apps web, IA, módulos ERP — cuéntame tu proyecto.