Use Cases (Atualizado: 02/06/2026)

Como implementar perfis de usuário seguros com Claude Code

Crie perfis com banco de dados, formulário, validação, autorização, avatar e logs de auditoria.

Como implementar perfis de usuário seguros com Claude Code

Um perfil de usuário parece uma tela pequena: nome público, bio e avatar. Em produção, essa tela toca em propriedade dos dados, campos públicos e privados, limites de upload, validação, autorização e logs de auditoria. Se você pedir ao Claude Code apenas “crie uma página de perfil”, ele pode entregar uma interface funcional e ainda deixar riscos importantes no fluxo.

Este guia mostra uma forma prática de implementar perfis com Claude Code usando Next.js App Router, Prisma, Zod, React e Sharp. A ideia principal é escrever os limites antes do código: quais campos o usuário pode editar, quais campos nunca devem vir do request e quais dados podem ser devolvidos em uma resposta pública.

Para a base de login, veja implementação de autenticação com Claude Code. Para entrada de dados, leia também validação de formulários com Claude Code. Como referências oficiais, use OWASP Mass Assignment Cheat Sheet, OWASP File Upload Cheat Sheet, Next.js Route Handlers e Prisma relations.

Escopo e limites de segurança

A função permite que um usuário autenticado edite o próprio perfil e escolha se ele será público. Os campos editáveis são username, nome de exibição, bio, localização, site, links sociais, visibilidade pública e avatar. Email, plano, função, permissões de equipe e verificação de email ficam fora.

Autenticação confirma quem é o usuário. Autorização decide o que esse usuário pode fazer. O perfil trata de apresentação, não de privilégio. Por isso o endpoint de perfil não deve aceitar role, plan, emailVerified ou userId do cliente.

ÁreaRegraMotivo
DonoAtualizar somente session.user.idImpede editar perfil de outra pessoa
EntradaAceitar apenas campos aprovados pelo ZodEvita mass assignment
RespostaSelecionar só campos seguros para públicoNão expõe email ou IDs internos
AvatarValidar MIME, tamanho, pixels e recodificarReduz riscos de upload
AuditoriaGuardar campos alterados e metadados mínimosRastreia sem 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

Modelo de dados

Separe User e Profile em uma relação um para um. User guarda identidade e estado da conta. Profile guarda dados de apresentação. As mudanças ficam em ProfileAuditLog.

// 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])
}

Não coloque role, plan, isAdmin ou emailVerified em Profile. Se um administrador precisar editar o perfil de um membro, crie uma rota administrativa separada e registre o administrador em actorUserId.

Validação com Zod

O Zod é a barreira entre input não confiável e banco de dados. O .strict() rejeita chaves desconhecidas, o que reduz o risco de campos inesperados serem persistidos no futuro.

// 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>;

O username é limitado porque aparece em URLs públicas. O nome de exibição é texto, não HTML. Links sociais podem ficar vazios, mas valores preenchidos precisam usar http ou https.

Rota de atualização

A rota lê a sessão no servidor e usa apenas session.user.id como alvo. O cliente não decide o dono do perfil. A atualização e o log de auditoria ficam na mesma transação.

// 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 });
}

A resposta usa select explícito. Ela não retorna email, role, plan, ID interno ou logs. O audit log não precisa armazenar a bio antiga completa nem todos os links antigos.

Upload de avatar

Upload de imagem precisa de validação no servidor. O exemplo abaixo grava em public/uploads/avatars para teste local. Em produção, substitua por armazenamento de objetos e política de limpeza.

// 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 });
}

Para produção, defina URLs assinadas, varredura de malware, invalidação de CDN, nomes imprevisíveis e remoção de avatares antigos. Claude Code implementa melhor quando essas decisões já estão escritas.

Formulário React

O formulário separa upload de avatar e salvamento JSON. Assim, uma falha de imagem não apaga texto, e uma falha de validação de texto não exige reenviar a imagem. O estado local é atualizado com a resposta do servidor.

// 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>
  );
}

Depois, adicione erros por campo, aviso de username duplicado, prévia de imagem, foco acessível e testes de botão desabilitado. Esses detalhes melhoram UX, mas não substituem o servidor.

Prompt e revisão com Claude Code

O prompt deve listar arquivos, tecnologias e proibições.

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

Faça uma segunda rodada pedindo revisão de segurança. Procure edição de perfil alheio, confiança em email ou role vindos do cliente, upload inseguro, PII em logs e respostas públicas com campos demais.

Casos de uso

O primeiro caso é perfil de conta SaaS. O usuário edita nome, departamento, avatar e bio. Plano e função ficam em outro fluxo.

O segundo é gestão de equipe. Admins podem ver perfis, mas mudanças de papel usam endpoint administrativo. Se o admin editar um perfil, o audit log precisa separar ator e alvo.

O terceiro é onboarding. Nome de usuário e nome de exibição ajudam convites e páginas públicas. Verificação de email e aceite de termos continuam como estados separados.

O quarto é perfil público. Apenas perfis isPublic aparecem em /users/[username]. A página pública não retorna email, ID interno, dados de equipe ou logs.

Erros comuns

O primeiro erro é aceitar o usuário alvo no request. Em edição própria, o alvo vem da sessão.

O segundo é passar ...body para o Prisma. Isso abre risco de mass assignment quando o modelo cresce.

O terceiro é validar imagem só no navegador. O servidor precisa limitar e recodificar.

O quarto é gravar PII demais em logs. Logs devem responder quem mudou o quê e quando, não duplicar todo o perfil.

O quinto é inconsistência de atualização otimista. Campos públicos devem ser atualizados pela resposta real do servidor.

Monetização e operação

Perfis influenciam receita. Em SaaS, perfis completos aumentam confiança em convites. Em comunidades, perfis públicos afetam reputação. Em treinamentos, perfis de instrutores e alunos influenciam a decisão de pedir uma consultoria.

O ClaudeCodeLab ajuda equipes a implementar com Claude Code superfícies pequenas, mas sensíveis: autenticação, perfis, telas administrativas, auditoria e ajustes próximos de billing. Para inserir perfis seguros em um produto existente ou treinar o time em revisão de Claude Code, veja treinamento e consultoria Claude Code.

Após o lançamento, acompanhe completude de perfil, falhas de avatar, usernames duplicados, visualizações de perfis públicos e cliques em CTA. Use auditoria para suporte e incidentes, não como segunda base de dados pessoais.

Resultado testado

Masa testou esse fluxo em uma pequena aplicação Next.js. A versão que começou com limites de dados e campos proibidos foi muito mais fácil de revisar. A versão pedida como “faça uma página bonita de perfil” carregava userId no estado do formulário, validava imagem só no cliente e salvava a bio antiga completa no audit log.

Antes de publicar, teste outro userId no JSON, campos role e emailVerified, imagem maior que 2MB, link javascript:, username duplicado e falha de salvamento. Claude Code acelera, mas a qualidade vem de checar propriedade, validação, logs e exposição pública.

#Claude Code #user profile #authentication #security #React
Grátis

PDF grátis: cheatsheet do Claude Code

Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.

Cuidamos dos seus dados e não enviamos spam.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.