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.
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.
| Área | Regra | Motivo |
|---|---|---|
| Dono | Atualizar somente session.user.id | Impede editar perfil de outra pessoa |
| Entrada | Aceitar apenas campos aprovados pelo Zod | Evita mass assignment |
| Resposta | Selecionar só campos seguros para público | Não expõe email ou IDs internos |
| Avatar | Validar MIME, tamanho, pixels e recodificar | Reduz riscos de upload |
| Auditoria | Guardar campos alterados e metadados mínimos | Rastreia 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.
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.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.