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.
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_SECRETgenerado connpx auth secret(no un string inventado) - Passwords hasheadas con
bcrypt(nunca en texto plano) -
redirect: falseen credentials para no filtrar info en la URL - Cookies con
httpOnlyysecure(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
- Estructura de carpetas en React/Next.js — cómo organizo la feature de auth
- Proteger tu API Node.js con JWT — auth desde cero sin Auth.js
- Supabase vs Firebase en producción — alternativas de auth as a service