Use Cases (Actualizado: 2/6/2026)

Implementar perfiles de usuario seguros con Claude Code

Guía práctica para crear perfiles con DB, formularios, validación, autorización, avatares y auditoría.

Implementar perfiles de usuario seguros con Claude Code

Un perfil de usuario parece una función pequeña: nombre visible, biografía y avatar. En producción, sin embargo, aparecen preguntas más serias: quién puede editarlo, qué campos pueden ser públicos, cómo limitar imágenes, cómo validar enlaces, cómo evitar que alguien cambie su rol desde el formulario y cómo dejar una pista de auditoría útil sin guardar demasiados datos personales.

Claude Code puede acelerar mucho esta implementación, pero necesita límites claros. Si el pedido es solo “crea una página de perfil”, es probable que genere una pantalla usable y aun así deje riesgos como aceptar userId desde el cuerpo de la petición, guardar ...body directamente en Prisma o confiar en accept="image/*" como si fuera seguridad.

Esta guía usa Next.js App Router, Prisma, Zod, React y Sharp. Para la base de autenticación puedes leer implementación de autenticación con Claude Code, y para formularios validación de formularios con Claude Code. Como referencias oficiales, revisa OWASP Mass Assignment Cheat Sheet, OWASP File Upload Cheat Sheet, Next.js Route Handlers y Prisma relations.

Alcance y límites de seguridad

La función permite que un usuario autenticado edite su propio perfil y decida si será público. Los campos editables son nombre de usuario, nombre visible, biografía, ubicación, sitio web, enlaces sociales, visibilidad pública y avatar. El correo, el plan, el rol, los permisos de equipo y la verificación de email quedan fuera.

Autenticación significa saber quién es la persona. Autorización significa decidir qué puede hacer esa persona. Un perfil controla cómo se presenta un usuario, no si es administrador ni qué plan paga. Esa separación debe estar en el prompt antes de pedirle cambios a Claude Code.

ÁreaReglaRazón
PropietarioActualizar solo session.user.idEvita editar perfiles ajenos
EntradaAceptar solo campos aprobados por ZodEvita mass assignment
RespuestaSeleccionar solo campos seguros para mostrarNo filtra email ni IDs internos
AvatarValidar MIME, tamaño, píxeles y recodificarReduce riesgo de subida insegura
AuditoríaGuardar campos cambiados y metadatos mínimosPermite investigar sin duplicar PII
flowchart TD
  A["Profile form"] --> B["Zod validation"]
  B --> C["Authorization check"]
  C --> D["Prisma transaction"]
  D --> E["Profile table"]
  D --> F["ProfileAuditLog"]
  A --> G["Avatar upload"]
  G --> H["MIME / size / pixel checks"]
  H --> I["Sharp resize to WebP"]
  I --> E

Diseño de base de datos

Separa User y Profile en una relación uno a uno. User conserva identidad y estado de cuenta; Profile contiene datos de presentación. La auditoría vive en ProfileAuditLog para saber quién cambió qué y cuándo.

// prisma/schema.prisma
model User {
  id               String            @id @default(cuid())
  email            String            @unique
  emailVerified    DateTime?
  profile          Profile?
  profileAuditLogs ProfileAuditLog[] @relation("ProfileAuditActor")
  createdAt        DateTime          @default(now())
  updatedAt        DateTime          @updatedAt
}

model Profile {
  id          String   @id @default(cuid())
  userId      String   @unique
  username    String   @unique
  displayName String
  bio         String   @default("")
  location    String   @default("")
  websiteUrl  String   @default("")
  avatarUrl   String?
  socialLinks Json     @default("{}")
  isPublic    Boolean  @default(false)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

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

  @@index([isPublic, updatedAt])
}

model ProfileAuditLog {
  id            String   @id @default(cuid())
  userId        String
  actorUserId   String
  action        String
  changedFields Json
  metadata      Json?
  createdAt     DateTime @default(now())

  actor User @relation("ProfileAuditActor", fields: [actorUserId], references: [id])

  @@index([userId, createdAt])
}

No incluyas role, plan, isAdmin ni emailVerified en Profile. Si más adelante un administrador puede editar el perfil de un miembro, crea una ruta administrativa separada y registra al administrador como actorUserId.

Validación con Zod

Zod define la frontera entre datos no confiables y la base de datos. El uso de .strict() hace que las claves desconocidas fallen, en lugar de pasar silenciosamente. Esto bloquea payloads con role, emailVerified o userId.

// src/lib/profile-schema.ts
import { z } from "zod";

const usernamePattern = /^[a-z0-9][a-z0-9_-]{2,29}$/;

function isHttpUrl(value: string) {
  if (value === "") return true;

  try {
    const url = new URL(value);
    return url.protocol === "http:" || url.protocol === "https:";
  } catch {
    return false;
  }
}

const optionalHttpUrl = z
  .string()
  .trim()
  .max(200)
  .refine(isHttpUrl, "Use http or https URLs only");

export const profileInputSchema = z
  .object({
    username: z
      .string()
      .trim()
      .regex(usernamePattern, "Use 3-30 lowercase letters, numbers, _ or -"),
    displayName: z.string().trim().min(1).max(40),
    bio: z.string().trim().max(280).default(""),
    location: z.string().trim().max(80).default(""),
    websiteUrl: optionalHttpUrl.default(""),
    socialLinks: z
      .object({
        github: optionalHttpUrl.default(""),
        x: optionalHttpUrl.default(""),
        linkedin: optionalHttpUrl.default(""),
      })
      .default({}),
    isPublic: z.boolean().default(false),
  })
  .strict();

export type ProfileInput = z.infer<typeof profileInputSchema>;

El nombre de usuario es estrecho porque forma parte de una URL pública. El nombre visible se renderiza como texto, no como HTML. Los enlaces sociales pueden estar vacíos, pero si tienen valor deben usar http o https.

API de actualización

La ruta debe leer la sesión en el servidor y fijar el propietario con session.user.id. No uses un userId recibido desde el cliente. Además, guarda el perfil y el registro de auditoría en una transacción para no dejar estados parciales.

// src/app/api/profile/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { profileInputSchema } from "@/lib/profile-schema";

const publicProfileSelect = {
  username: true,
  displayName: true,
  bio: true,
  location: true,
  websiteUrl: true,
  avatarUrl: true,
  socialLinks: true,
  isPublic: true,
  updatedAt: true,
} as const;

function changedKeys(before: Record<string, unknown> | null, after: Record<string, unknown>) {
  if (!before) return Object.keys(after);

  return Object.keys(after).filter((key) => {
    return JSON.stringify(before[key]) !== JSON.stringify(after[key]);
  });
}

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

  if (!session?.user?.id) {
    return NextResponse.json({ error: "Authentication required" }, { status: 401 });
  }

  const profile = await prisma.profile.findUnique({
    where: { userId: session.user.id },
    select: publicProfileSelect,
  });

  return NextResponse.json({ profile });
}

export async function PUT(request: Request) {
  const session = await auth();

  if (!session?.user?.id) {
    return NextResponse.json({ error: "Authentication required" }, { status: 401 });
  }

  const json = await request.json().catch(() => null);
  const parsed = profileInputSchema.safeParse(json);

  if (!parsed.success) {
    return NextResponse.json(
      { error: "Invalid profile input", issues: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const userId = session.user.id;
  const input = parsed.data;
  const before = await prisma.profile.findUnique({ where: { userId } });
  const fields = changedKeys(before, input);

  const profile = await prisma.$transaction(async (tx) => {
    const saved = await tx.profile.upsert({
      where: { userId },
      update: input,
      create: { userId, ...input },
      select: publicProfileSelect,
    });

    if (fields.length > 0) {
      await tx.profileAuditLog.create({
        data: {
          userId,
          actorUserId: userId,
          action: before ? "profile.update" : "profile.create",
          changedFields: fields,
          metadata: {
            source: "profile-settings",
            beforeDisplayName: before?.displayName ?? null,
          },
        },
      });
    }

    return saved;
  });

  return NextResponse.json({ profile });
}

La respuesta usa un select explícito. No devuelve email, rol, plan, IDs internos ni logs. La auditoría guarda nombres de campos, actor, destino y contexto mínimo; no copia la biografía anterior completa ni todos los enlaces anteriores.

Subida de avatar

La subida de imágenes es una de las zonas más sensibles. El ejemplo guarda WebP en public/uploads/avatars para facilitar pruebas locales. En producción, reemplaza esa parte por S3, R2, GCS u otro almacenamiento de objetos.

// src/app/api/profile/avatar/route.ts
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import sharp from "sharp";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";

export const runtime = "nodejs";

const MAX_BYTES = 2 * 1024 * 1024;
const MAX_PIXELS = 4096 * 4096;
const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp"]);

export async function POST(request: Request) {
  const session = await auth();

  if (!session?.user?.id) {
    return NextResponse.json({ error: "Authentication required" }, { status: 401 });
  }

  const formData = await request.formData();
  const file = formData.get("avatar");

  if (!(file instanceof File)) {
    return NextResponse.json({ error: "Avatar file is required" }, { status: 400 });
  }

  if (!allowedTypes.has(file.type)) {
    return NextResponse.json({ error: "Use JPEG, PNG, or WebP" }, { status: 400 });
  }

  if (file.size > MAX_BYTES) {
    return NextResponse.json({ error: "Avatar must be 2MB or smaller" }, { status: 400 });
  }

  const input = Buffer.from(await file.arrayBuffer());
  const image = sharp(input, { limitInputPixels: MAX_PIXELS });
  const metadata = await image.metadata();

  if (!metadata.width || !metadata.height || metadata.width < 64 || metadata.height < 64) {
    return NextResponse.json({ error: "Image must be at least 64x64 pixels" }, { status: 400 });
  }

  const output = await sharp(input, { limitInputPixels: MAX_PIXELS })
    .rotate()
    .resize(256, 256, { fit: "cover" })
    .webp({ quality: 82 })
    .toBuffer();

  const fileName = `${session.user.id}-${randomUUID()}.webp`;
  const uploadDir = path.join(process.cwd(), "public", "uploads", "avatars");
  await mkdir(uploadDir, { recursive: true });
  await writeFile(path.join(uploadDir, fileName), output);

  const avatarUrl = `/uploads/avatars/${fileName}`;

  await prisma.$transaction(async (tx) => {
    await tx.profile.update({
      where: { userId: session.user.id },
      data: { avatarUrl },
    });

    await tx.profileAuditLog.create({
      data: {
        userId: session.user.id,
        actorUserId: session.user.id,
        action: "profile.avatar.update",
        changedFields: ["avatarUrl"],
        metadata: { contentType: "image/webp", bytes: output.byteLength },
      },
    });
  });

  return NextResponse.json({ avatarUrl });
}

En producción también define limpieza de avatares antiguos, URLs firmadas, análisis de malware, nombres no predecibles y política de caché. Claude Code puede implementarlo, pero necesita esa política escrita.

Formulario React

El formulario separa la subida del avatar y el guardado JSON del perfil. Esto simplifica errores y reintentos. El estado local se actualiza con la respuesta del servidor después de guardar, no con una suposición optimista.

// src/components/ProfileForm.tsx
"use client";

import { useState, useTransition } from "react";
import type { ProfileInput } from "@/lib/profile-schema";

type ProfileFormProps = {
  initialProfile: ProfileInput & { avatarUrl?: string | null };
};

export function ProfileForm({ initialProfile }: ProfileFormProps) {
  const [form, setForm] = useState<ProfileInput>(initialProfile);
  const [avatarUrl, setAvatarUrl] = useState(initialProfile.avatarUrl ?? "");
  const [message, setMessage] = useState("");
  const [isPending, startTransition] = useTransition();

  function updateField<K extends keyof ProfileInput>(key: K, value: ProfileInput[K]) {
    setForm((current) => ({ ...current, [key]: value }));
  }

  async function uploadAvatar(file: File) {
    setMessage("");

    if (file.size > 2 * 1024 * 1024) {
      setMessage("Avatar must be 2MB or smaller.");
      return;
    }

    const body = new FormData();
    body.append("avatar", file);

    const response = await fetch("/api/profile/avatar", {
      method: "POST",
      body,
    });

    const result = await response.json();

    if (!response.ok) {
      setMessage(result.error ?? "Avatar upload failed.");
      return;
    }

    setAvatarUrl(result.avatarUrl);
    setMessage("Avatar updated.");
  }

  function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setMessage("");

    startTransition(async () => {
      const response = await fetch("/api/profile", {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(form),
      });

      const result = await response.json();

      if (!response.ok) {
        setMessage(result.error ?? "Profile save failed.");
        return;
      }

      setForm(result.profile);
      setMessage("Profile saved.");
    });
  }

  return (
    <form onSubmit={submit} className="mx-auto max-w-2xl space-y-5">
      <div className="flex items-center gap-4">
        <img
          src={avatarUrl || "/images/default-avatar.png"}
          alt=""
          className="h-20 w-20 rounded-full object-cover"
        />
        <label className="block">
          <span className="text-sm font-medium">Avatar image</span>
          <input
            type="file"
            accept="image/jpeg,image/png,image/webp"
            onChange={(event) => {
              const file = event.currentTarget.files?.[0];
              if (file) void uploadAvatar(file);
            }}
            className="mt-2 block text-sm"
          />
        </label>
      </div>

      <label className="block">
        <span className="text-sm font-medium">Username</span>
        <input
          value={form.username}
          onChange={(event) => updateField("username", event.target.value as ProfileInput["username"])}
          className="mt-1 w-full rounded border px-3 py-2"
          autoComplete="off"
        />
      </label>

      <label className="block">
        <span className="text-sm font-medium">Display name</span>
        <input
          value={form.displayName}
          onChange={(event) => updateField("displayName", event.target.value)}
          className="mt-1 w-full rounded border px-3 py-2"
          maxLength={40}
        />
      </label>

      <label className="block">
        <span className="text-sm font-medium">Bio</span>
        <textarea
          value={form.bio}
          onChange={(event) => updateField("bio", event.target.value)}
          className="mt-1 h-28 w-full rounded border px-3 py-2"
          maxLength={280}
        />
        <span className="text-xs text-slate-500">{form.bio.length}/280</span>
      </label>

      <label className="flex items-center gap-2">
        <input
          type="checkbox"
          checked={form.isPublic}
          onChange={(event) => updateField("isPublic", event.target.checked)}
        />
        <span>Show this profile publicly</span>
      </label>

      <button
        type="submit"
        disabled={isPending}
        className="rounded bg-slate-900 px-4 py-2 font-medium text-white disabled:bg-slate-400"
      >
        {isPending ? "Saving..." : "Save profile"}
      </button>

      {message && <p className="text-sm text-slate-700">{message}</p>}
    </form>
  );
}

Añade después errores por campo, mensaje de usuario duplicado, vista previa de imagen, gestión de foco y pruebas de estados deshabilitados. Pero esos detalles no sustituyen la validación del servidor.

Prompt para Claude Code y revisión

Un buen prompt evita ambigüedad y obliga a revisar seguridad.

Implement a user profile feature in this Next.js App Router project.

Scope:
- Edit only the files needed for profile schema, API routes, avatar upload, and ProfileForm.
- Use Prisma for Profile and ProfileAuditLog.
- Use Zod for request validation.
- Never accept userId, email, role, plan, or emailVerified from the request body.
- Use session.user.id as the only profile owner.
- Return only public-safe profile fields from API responses.
- Limit avatars to JPEG, PNG, or WebP, 2MB max, then resize to 256x256 WebP.
- Create an audit log row for profile and avatar changes.

After implementation, review your own diff for:
- mass assignment
- broken authorization
- unsafe file upload
- PII in logs
- optimistic UI inconsistency
- missing tests or manual verification steps

Después de la implementación, pide una segunda pasada centrada en seguridad. Debe buscar edición de perfiles ajenos, confianza en campos de email o rol enviados por el cliente, subida de imágenes insegura, PII en logs y respuestas públicas demasiado amplias.

Casos de uso

El primer caso es un perfil de cuenta SaaS. El usuario edita nombre, departamento, avatar y biografía; el plan y el rol viven en otro flujo.

El segundo es gestión de equipos. Un administrador puede ver perfiles, pero los cambios de rol deben usar una ruta distinta. Si edita el perfil de un miembro, la auditoría debe separar actor y objetivo.

El tercero es onboarding. Pedir nombre de usuario y nombre visible en el primer acceso facilita invitaciones y páginas públicas. La aceptación de términos y la verificación de email siguen siendo estados separados.

El cuarto es un perfil público. Solo los perfiles con isPublic deben mostrarse en /users/[username]. Esa página no debe devolver email, IDs internos, información privada de equipo ni logs.

Errores comunes

El primer error es aceptar el ID del usuario objetivo desde la petición. En un endpoint de autoservicio, el propietario sale de la sesión.

El segundo es pasar ...body a Prisma. Es cómodo, pero abre la puerta a mass assignment cuando el modelo crece.

El tercero es confiar en el selector de archivos del navegador. El servidor debe validar y recodificar.

El cuarto es guardar demasiada PII en logs. Los logs deben responder quién cambió qué y cuándo, no duplicar el perfil completo.

El quinto es una actualización optimista que queda falsa. Para campos públicos, la UI debe reflejar la respuesta real del servidor.

Monetización y operación

Los perfiles afectan conversión. En SaaS aumentan confianza en invitaciones, en comunidades influyen en reputación y en formación ayudan a decidir si pedir una consulta. Una pantalla de perfil débil puede hacer que el usuario abandone antes de llegar al CTA.

ClaudeCodeLab ayuda a equipos a implementar con Claude Code estas funciones pequeñas pero sensibles: autenticación, perfiles, pantallas administrativas, auditoría y flujos cercanos a facturación. Si quieres añadir perfiles seguros a un producto existente o entrenar al equipo en revisión de salidas de Claude Code, revisa formación y consultoría de Claude Code.

Tras lanzar, mide completitud de perfil, fallos de avatar, errores de nombre duplicado, visitas a perfiles públicos y clics de CTA. Usa auditoría para soporte e incidentes, no como almacén secundario de datos personales.

Resultado probado

Masa probó este flujo en una aplicación pequeña de Next.js. Cuando primero definió límites de base de datos y campos prohibidos, la revisión fue mucho más rápida. La versión iniciada con “haz una página de perfil bonita” incluyó userId en estado de formulario, validación de imagen solo en cliente y logs con biografías antiguas completas.

Antes de publicar, prueba enviar otro userId, enviar role y emailVerified, subir una imagen mayor a 2MB, guardar un enlace javascript:, duplicar un username y forzar un fallo de guardado. Claude Code acelera, pero la calidad del perfil depende de revisar propiedad, validación, logs y exposición pública.

#Claude Code #user profile #authentication #security #React
Gratis

PDF gratis: cheatsheet de Claude Code

Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.

Cuidamos tus datos y no enviamos spam.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.