Por qué implementar tu propia autenticación
Librerías como NextAuth (ahora Auth.js) son fantásticas para añadir autenticación rápidamente, pero abstraen tanto que muchos desarrolladores no entienden qué ocurre por debajo. Implementar tu propio sistema de autenticación con JWT, cookies HttpOnly y middleware de Next.js te da control total sobre el flujo, te enseña los fundamentos de seguridad web y te permite personalizar cada aspecto sin depender de las limitaciones de una librería externa.
En esta guía construiremos un sistema de autenticación completo desde cero usando
Next.js 15
con App Router, la librería jose para JWT, bcryptjs para hashing
de contraseñas y cookies HttpOnly para almacenamiento seguro de tokens.
Antes de empezar, instala las dependencias necesarias:
# Librería para firmar y verificar JWT (compatible con Edge Runtime)
npm install jose
# Hashing de contraseñas
npm install bcryptjs
npm install -D @types/bcryptjsNota importante: Usamos
joseen lugar dejsonwebtokenporque jose es compatible con el Edge Runtime de Next.js, que es donde se ejecuta el middleware.jsonwebtokendepende de módulos nativos de Node.js que no están disponibles en Edge.
Hashing de contraseñas con bcryptjs
Nunca almacenes contraseñas en texto plano. bcryptjs genera un hash seguro con
un salt incorporado que hace que cada hash sea único, incluso para contraseñas idénticas.
// lib/auth/password.ts
import bcrypt from "bcryptjs";
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
return bcrypt.compare(password, hashedPassword);
}
El parámetro SALT_ROUNDS controla la complejidad computacional del hashing.
Un valor de 12 proporciona un buen equilibrio entre seguridad y rendimiento. Valores
más altos hacen el hash más lento (y más resistente a ataques de fuerza bruta), pero
también aumentan el tiempo de respuesta del endpoint de login.
Crear registros de usuario
Cuando registras un nuevo usuario, hashea la contraseña antes de guardarla en la base de datos:
// app/api/register/route.ts
import { NextRequest, NextResponse } from "next/server";
import { hashPassword } from "@/lib/auth/password";
import { z } from "zod";
const registerSchema = z.object({
email: z.string().email("Email inválido"),
password: z
.string()
.min(8, "La contraseña debe tener al menos 8 caracteres")
.regex(/[A-Z]/, "Debe contener al menos una mayúscula")
.regex(/[0-9]/, "Debe contener al menos un número"),
name: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password, name } = registerSchema.parse(body);
// 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: 409 }
);
}
// Hashear la contraseña antes de guardarla
const hashedPassword = await hashPassword(password);
const user = await db.user.create({
data: { email, password: hashedPassword, name },
});
return NextResponse.json(
{ message: "Usuario creado correctamente", userId: user.id },
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Datos inválidos", details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Error interno del servidor" },
{ status: 500 }
);
}
}Crear y verificar tokens JWT con jose
Un JSON Web Token (JWT) es un string codificado en Base64 que contiene un payload firmado digitalmente. El servidor firma el token con un secreto y puede verificar su autenticidad en cada request sin consultar la base de datos.
// lib/auth/jwt.ts
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "your-secret-key-min-32-characters-long!"
);
const JWT_ISSUER = "your-app-name";
const JWT_AUDIENCE = "your-app-users";
export interface TokenPayload extends JWTPayload {
userId: string;
email: string;
role: "user" | "admin";
}
export async function signToken(payload: Omit<TokenPayload, "iat" | "exp" | "iss" | "aud">): Promise<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setExpirationTime("24h")
.sign(JWT_SECRET);
}
export async function verifyToken(token: string): Promise<TokenPayload> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET, {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
});
return payload as TokenPayload;
} catch (error) {
throw new Error("Token inválido o expirado");
}
}Puntos clave de esta implementación:
- Algoritmo HS256: Usa HMAC con SHA-256 para firmar el token. Es simétrico, lo que significa que se usa el mismo secreto para firmar y verificar.
- setIssuedAt(): Añade automáticamente el timestamp de emisión (
iat) al token. - setExpirationTime("24h"): El token expira automáticamente en 24 horas. Tras eso,
jwtVerifyrechazará el token. - Issuer y Audience: Campos adicionales de seguridad que previenen que tokens de otros servicios sean válidos en tu aplicación.
Tokens de refresco
Para sesiones de larga duración, implementa un sistema de token de refresco. El access token tiene una vida corta (15 minutos) y el refresh token dura más (7 días):
// lib/auth/tokens.ts
import { signToken, verifyToken } from "./jwt";
export async function generateTokenPair(user: {
id: string;
email: string;
role: "user" | "admin";
}) {
const accessToken = await new SignJWT({
userId: user.id,
email: user.email,
role: user.role,
type: "access",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(JWT_SECRET);
const refreshToken = await new SignJWT({
userId: user.id,
type: "refresh",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(JWT_SECRET);
return { accessToken, refreshToken };
}Cookies HttpOnly para almacenamiento seguro
Las cookies HttpOnly son la forma más segura de almacenar tokens de autenticación en el
navegador. A diferencia de localStorage o sessionStorage, las
cookies HttpOnly no son accesibles desde JavaScript, lo que las protege contra ataques XSS.
// lib/auth/cookies.ts
import { cookies } from "next/headers";
const COOKIE_NAME = "auth-token";
const REFRESH_COOKIE_NAME = "refresh-token";
interface CookieOptions {
maxAge?: number;
path?: string;
}
export async function setAuthCookie(
token: string,
options: CookieOptions = {}
) {
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: options.path || "/",
maxAge: options.maxAge || 60 * 60 * 24, // 24 horas
});
}
export async function setRefreshCookie(token: string) {
const cookieStore = await cookies();
cookieStore.set(REFRESH_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/api/auth/refresh",
maxAge: 60 * 60 * 24 * 7, // 7 días
});
}
export async function getAuthCookie(): Promise<string | undefined> {
const cookieStore = await cookies();
return cookieStore.get(COOKIE_NAME)?.value;
}
export async function removeAuthCookies() {
const cookieStore = await cookies();
cookieStore.delete(COOKIE_NAME);
cookieStore.delete(REFRESH_COOKIE_NAME);
}Cada atributo de la cookie tiene un propósito de seguridad específico:
- httpOnly: true — Impide que JavaScript del cliente acceda a la cookie, protegiendo contra XSS.
- secure: true — La cookie solo se envía a través de HTTPS (activado solo en producción).
- sameSite: "lax" — Previene que la cookie se envíe en requests cross-site (protección CSRF), pero permite la navegación normal desde enlaces externos.
- path: "/" — La cookie está disponible en todas las rutas de la aplicación.
- maxAge — Tiempo de vida de la cookie en segundos.
Ruta API de login
El endpoint de login valida las credenciales del usuario, genera un JWT y lo establece como cookie HttpOnly en la respuesta:
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { verifyPassword } from "@/lib/auth/password";
import { signToken } from "@/lib/auth/jwt";
import { setAuthCookie } from "@/lib/auth/cookies";
const loginSchema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(1, "La contraseña es obligatoria"),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = loginSchema.parse(body);
// Buscar el usuario en la base de datos
const user = await db.user.findUnique({
where: { email },
select: { id: true, email: true, password: true, role: true, name: true },
});
if (!user) {
return NextResponse.json(
{ error: "Credenciales inválidas" },
{ status: 401 }
);
}
// Verificar la contraseña
const isValid = await verifyPassword(password, user.password);
if (!isValid) {
return NextResponse.json(
{ error: "Credenciales inválidas" },
{ status: 401 }
);
}
// Generar el token JWT
const token = await signToken({
userId: user.id,
email: user.email,
role: user.role,
});
// Establecer la cookie HttpOnly
await setAuthCookie(token);
// Devolver datos del usuario (sin la contraseña)
return NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Datos inválidos", details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Error interno del servidor" },
{ status: 500 }
);
}
}Buena práctica de seguridad: Nunca reveles si el email existe o no en la base de datos. Devuelve siempre el mismo mensaje de error genérico ("Credenciales inválidas") tanto si el email no existe como si la contraseña es incorrecta. Esto previene la enumeración de usuarios.
Middleware para proteger rutas
El middleware de Next.js se ejecuta antes de cada request, lo que lo hace ideal para verificar
la autenticación. Se ejecuta en el Edge Runtime, por eso necesitamos la librería jose
(compatible con Edge) en lugar de jsonwebtoken.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "your-secret-key-min-32-characters-long!"
);
// Rutas que requieren autenticación
const protectedRoutes = ["/dashboard", "/profile", "/settings", "/admin"];
// Rutas que solo son accesibles sin autenticación
const authRoutes = ["/login", "/register"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const token = request.cookies.get("auth-token")?.value;
// Verificar si la ruta es protegida
const isProtectedRoute = protectedRoutes.some((route) =>
pathname.startsWith(route)
);
const isAuthRoute = authRoutes.some((route) =>
pathname.startsWith(route)
);
// Si no hay token y la ruta es protegida, redirigir al login
if (isProtectedRoute && !token) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
// Si hay token, verificar que es válido
if (token) {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
// Si el usuario autenticado intenta acceder a rutas de auth, redirigir al dashboard
if (isAuthRoute) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
// Verificar permisos de admin
if (pathname.startsWith("/admin") && payload.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
// Añadir datos del usuario a los headers para uso posterior
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user-id", payload.userId as string);
requestHeaders.set("x-user-role", payload.role as string);
return NextResponse.next({
request: { headers: requestHeaders },
});
} catch (error) {
// Token inválido o expirado: eliminar cookie y redirigir
if (isProtectedRoute) {
const response = NextResponse.redirect(
new URL("/login", request.url)
);
response.cookies.delete("auth-token");
return response;
}
}
}
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|public).*)",
],
};
El matcher en la configuración del middleware define qué rutas activan el
middleware. Excluimos las rutas de API, archivos estáticos e imágenes para evitar
verificaciones innecesarias.
Contexto de autenticación para componentes cliente
Para que los componentes cliente puedan acceder al estado de autenticación, creamos un Context Provider que obtiene los datos del usuario al montar la aplicación:
// contexts/AuthContext.tsx
"use client";
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react";
import { useRouter } from "next/navigation";
interface User {
id: string;
email: string;
name: string;
role: "user" | "admin";
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const refreshUser = useCallback(async () => {
try {
const response = await fetch("/api/auth/me");
if (response.ok) {
const data = await response.json();
setUser(data.user);
} else {
setUser(null);
}
} catch {
setUser(null);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
refreshUser();
}, [refreshUser]);
const login = useCallback(
async (email: string, password: string) => {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Error al iniciar sesión");
}
const data = await response.json();
setUser(data.user);
router.push("/dashboard");
},
[router]
);
const logout = useCallback(async () => {
await fetch("/api/auth/logout", { method: "POST" });
setUser(null);
router.push("/login");
}, [router]);
return (
<AuthContext.Provider
value={{ user, isLoading, login, logout, refreshUser }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth debe usarse dentro de un AuthProvider");
}
return context;
}
Usa el Provider en tu layout raíz y el hook useAuth() en cualquier componente
cliente que necesite acceder al estado de autenticación:
// Ejemplo de uso en un componente
"use client";
import { useAuth } from "@/contexts/AuthContext";
export function UserMenu() {
const { user, isLoading, logout } = useAuth();
if (isLoading) return <div className="animate-pulse h-8 w-8 rounded-full bg-gray-200" />;
if (!user) return null;
return (
<div className="flex items-center gap-3">
<span className="text-sm text-gray-700">{user.name}</span>
<button
onClick={logout}
className="text-sm text-red-600 hover:text-red-800"
>
Cerrar sesión
</button>
</div>
);
}Rutas API protegidas con withAuth
Para proteger rutas API individuales, creamos una función de orden superior (higher-order function) que verifica la autenticación antes de ejecutar el handler:
// lib/auth/withAuth.ts
import { NextRequest, NextResponse } from "next/server";
import { getAuthCookie } from "./cookies";
import { verifyToken, type TokenPayload } from "./jwt";
type AuthenticatedHandler = (
request: NextRequest,
context: { params: Promise<Record<string, string>>; user: TokenPayload }
) => Promise<NextResponse>;
export function withAuth(handler: AuthenticatedHandler) {
return async (
request: NextRequest,
context: { params: Promise<Record<string, string>> }
) => {
const token = await getAuthCookie();
if (!token) {
return NextResponse.json(
{ error: "No autenticado" },
{ status: 401 }
);
}
try {
const user = await verifyToken(token);
return handler(request, { ...context, user });
} catch (error) {
return NextResponse.json(
{ error: "Token inválido o expirado" },
{ status: 401 }
);
}
};
}
// Variante que requiere rol de admin
export function withAdmin(handler: AuthenticatedHandler) {
return withAuth(async (request, context) => {
if (context.user.role !== "admin") {
return NextResponse.json(
{ error: "Acceso denegado: se requiere rol de administrador" },
{ status: 403 }
);
}
return handler(request, context);
});
}Uso en una ruta API protegida:
// app/api/profile/route.ts
import { NextRequest, NextResponse } from "next/server";
import { withAuth } from "@/lib/auth/withAuth";
export const GET = withAuth(async (request, { user }) => {
const profile = await db.user.findUnique({
where: { id: user.userId },
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
return NextResponse.json({ profile });
});
export const PUT = withAuth(async (request, { user }) => {
const body = await request.json();
const updatedProfile = await db.user.update({
where: { id: user.userId },
data: { name: body.name },
select: { id: true, email: true, name: true },
});
return NextResponse.json({ profile: updatedProfile });
});Logout y expiración de sesiones
El cierre de sesión elimina las cookies de autenticación del navegador. Dado que los JWT son stateless (no se almacenan en el servidor), no puedes "invalidar" un token directamente. Sin embargo, hay estrategias para manejar esto correctamente.
// app/api/auth/logout/route.ts
import { NextResponse } from "next/server";
import { removeAuthCookies } from "@/lib/auth/cookies";
export async function POST() {
await removeAuthCookies();
return NextResponse.json({ message: "Sesión cerrada correctamente" });
}Lista negra de tokens (opcional)
Si necesitas invalidar tokens antes de su expiración natural (por ejemplo, cuando un admin revoca el acceso de un usuario), implementa una lista negra usando Redis o una base de datos:
// lib/auth/tokenBlacklist.ts
const blacklistedTokens = new Set<string>();
export function blacklistToken(token: string, expiresAt: number) {
blacklistedTokens.add(token);
// Limpiar automáticamente cuando expire
const ttl = (expiresAt * 1000) - Date.now();
if (ttl > 0) {
setTimeout(() => blacklistedTokens.delete(token), ttl);
}
}
export function isTokenBlacklisted(token: string): boolean {
return blacklistedTokens.has(token);
}
// En producción, usa Redis:
// import { Redis } from "@upstash/redis";
// const redis = new Redis({ url: "...", token: "..." });
// export async function blacklistToken(token: string, expiresAt: number) {
// const ttl = expiresAt - Math.floor(Date.now() / 1000);
// await redis.set(`blacklist:${token}`, "1", { ex: ttl });
// }Endpoint /api/auth/me
Este endpoint permite al cliente verificar si la sesión actual es válida y obtener los datos del usuario:
// app/api/auth/me/route.ts
import { NextResponse } from "next/server";
import { getAuthCookie } from "@/lib/auth/cookies";
import { verifyToken } from "@/lib/auth/jwt";
export async function GET() {
const token = await getAuthCookie();
if (!token) {
return NextResponse.json({ user: null }, { status: 401 });
}
try {
const payload = await verifyToken(token);
const user = await db.user.findUnique({
where: { id: payload.userId },
select: { id: true, email: true, name: true, role: true },
});
if (!user) {
return NextResponse.json({ user: null }, { status: 401 });
}
return NextResponse.json({ user });
} catch {
return NextResponse.json({ user: null }, { status: 401 });
}
}Buenas prácticas de seguridad
Implementar autenticación correctamente requiere atención a múltiples vectores de ataque. Estas son las prácticas esenciales que debes seguir:
Protección CSRF
Las cookies con SameSite: "lax" previenen la mayoría de ataques CSRF, pero
para máxima seguridad puedes implementar tokens CSRF adicionales:
// lib/auth/csrf.ts
import { randomBytes } from "crypto";
export function generateCsrfToken(): string {
return randomBytes(32).toString("hex");
}
export function validateCsrfToken(
requestToken: string | null,
sessionToken: string
): boolean {
if (!requestToken) return false;
// Comparación de tiempo constante para prevenir timing attacks
if (requestToken.length !== sessionToken.length) return false;
let result = 0;
for (let i = 0; i < requestToken.length; i++) {
result |= requestToken.charCodeAt(i) ^ sessionToken.charCodeAt(i);
}
return result === 0;
}Rate limiting en login
Protege el endpoint de login contra ataques de fuerza bruta limitando el número de intentos por IP:
// lib/auth/rateLimit.ts
const loginAttempts = new Map<string, { count: number; lastAttempt: number }>();
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 15 * 60 * 1000; // 15 minutos
export function checkRateLimit(ip: string): {
allowed: boolean;
remainingAttempts: number;
retryAfter?: number;
} {
const now = Date.now();
const record = loginAttempts.get(ip);
if (!record || now - record.lastAttempt > WINDOW_MS) {
loginAttempts.set(ip, { count: 1, lastAttempt: now });
return { allowed: true, remainingAttempts: MAX_ATTEMPTS - 1 };
}
if (record.count >= MAX_ATTEMPTS) {
const retryAfter = Math.ceil(
(record.lastAttempt + WINDOW_MS - now) / 1000
);
return { allowed: false, remainingAttempts: 0, retryAfter };
}
record.count++;
record.lastAttempt = now;
return { allowed: true, remainingAttempts: MAX_ATTEMPTS - record.count };
}Flags de cookies seguras
Recapitulemos los atributos de cookie esenciales y cuándo usar cada combinación:
- HttpOnly: Siempre
truepara tokens de autenticación. Previene acceso desde JavaScript del cliente. - Secure:
trueen producción. Requiere HTTPS para enviar la cookie. - SameSite: Usa
"strict"para máxima seguridad (la cookie no se envía en ningún request cross-site) o"lax"para permitir navegación desde enlaces externos. - Path: Restringe el scope de la cookie. Para refresh tokens, usa
"/api/auth/refresh"para que solo se envíen a ese endpoint. - MaxAge: Define la duración de la cookie. Alinéalo con la expiración del JWT.
Rotación de tokens
La rotación de tokens minimiza el riesgo si un token es comprometido. Cada vez que se usa el refresh token, genera un nuevo par de tokens y revoca el anterior:
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
import { generateTokenPair } from "@/lib/auth/tokens";
import { setAuthCookie, setRefreshCookie } from "@/lib/auth/cookies";
export async function POST(request: NextRequest) {
const refreshToken = request.cookies.get("refresh-token")?.value;
if (!refreshToken) {
return NextResponse.json(
{ error: "No hay refresh token" },
{ status: 401 }
);
}
try {
const { payload } = await jwtVerify(refreshToken, JWT_SECRET);
if (payload.type !== "refresh") {
return NextResponse.json(
{ error: "Token inválido" },
{ status: 401 }
);
}
// Verificar que el usuario aún existe y está activo
const user = await db.user.findUnique({
where: { id: payload.userId as string },
select: { id: true, email: true, role: true, isActive: true },
});
if (!user || !user.isActive) {
return NextResponse.json(
{ error: "Usuario no encontrado o desactivado" },
{ status: 401 }
);
}
// Generar nuevo par de tokens
const tokens = await generateTokenPair(user);
// Establecer nuevas cookies
await setAuthCookie(tokens.accessToken);
await setRefreshCookie(tokens.refreshToken);
return NextResponse.json({ message: "Tokens renovados" });
} catch (error) {
return NextResponse.json(
{ error: "Refresh token inválido o expirado" },
{ status: 401 }
);
}
}Variables de entorno necesarias
Asegúrate de configurar estas variables de entorno en tu proyecto:
# .env.local (NUNCA subas este archivo al repositorio)
JWT_SECRET=tu-secreto-de-al-menos-32-caracteres-generado-aleatoriamente
DATABASE_URL=postgresql://user:password@localhost:5432/mydb// environment.d.ts
declare namespace NodeJS {
interface ProcessEnv {
JWT_SECRET: string;
DATABASE_URL: string;
}
}Resumen: Implementar tu propia autenticación con JWT y cookies HttpOnly te da control total sobre la seguridad de tu aplicación. Los puntos clave son: hashear siempre las contraseñas con bcrypt, usar jose para tokens JWT compatibles con Edge Runtime, almacenar tokens en cookies HttpOnly con los flags de seguridad correctos, proteger rutas con middleware de Next.js y rotar tokens periódicamente. Aunque exige más trabajo inicial que usar NextAuth, el conocimiento que adquieres es invaluable para cualquier desarrollador fullstack.