Créer un profil utilisateur sécurisé avec Claude Code
Implémentez profils, base de données, formulaire, validation, autorisation, avatars et audit avec Claude Code.
Un profil utilisateur semble souvent simple: un nom affiché, une courte bio et une image. En production, cette petite fonctionnalité touche pourtant à la propriété des données, aux champs publics, aux limites d’upload, à la validation, à l’autorisation et aux journaux d’audit. Si la demande à Claude Code se limite à “ajoute une page de profil”, l’interface peut être correcte alors que les règles critiques restent absentes.
L’objectif de ce guide est de montrer comment cadrer Claude Code avant qu’il modifie le projet. Nous allons utiliser Next.js App Router, Prisma, Zod, React et Sharp. Le point important est la frontière: le profil contrôle la présentation d’un utilisateur, pas son rôle, son plan de facturation ou son statut de vérification email.
Pour les bases proches, lisez aussi l’implémentation de l’authentification avec Claude Code et la validation de formulaires avec Claude Code. Pour les références officielles, utilisez OWASP Mass Assignment Cheat Sheet, OWASP File Upload Cheat Sheet, Next.js Route Handlers et Prisma relations.
Périmètre et limites de sécurité
La fonctionnalité permet à un utilisateur connecté de modifier son propre profil et de le rendre public si nécessaire. Les champs éditables sont le nom d’utilisateur, le nom affiché, la bio, la localisation, le site web, les liens sociaux, la visibilité publique et l’avatar. L’email, le rôle, le plan, les permissions d’équipe et la vérification email restent ailleurs.
L’authentification répond à la question “qui est cette personne”. L’autorisation répond à la question “a-t-elle le droit de faire cette action”. Un endpoint de profil ne doit donc jamais accepter un rôle ou un identifiant propriétaire depuis le client.
| Sujet | Règle | Pourquoi |
|---|---|---|
| Propriétaire | Mettre à jour uniquement session.user.id | Empêche de modifier un autre profil |
| Entrée | Accepter seulement les champs validés par Zod | Évite le mass assignment |
| Réponse | Sélectionner les champs publics sûrs | Ne divulgue pas email ni ID interne |
| Avatar | Vérifier MIME, taille, pixels, puis réencoder | Réduit le risque d’upload dangereux |
| Audit | Stocker les champs modifiés et peu de métadonnées | Garde une trace sans recopier la 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
Modèle de données
Séparez User et Profile avec une relation un à un. User contient l’identité et l’état du compte. Profile contient les informations de présentation. ProfileAuditLog garde la trace des changements sans devenir une copie complète des données personnelles.
// 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])
}
Ne mettez pas role, plan, isAdmin ou emailVerified dans Profile. Si un administrateur doit modifier le profil d’un membre, créez un endpoint admin séparé et enregistrez l’administrateur comme actorUserId.
Validation avec Zod
Zod protège la base de données contre les corps de requête non fiables. Le .strict() est volontaire: toute clé inconnue provoque une erreur. C’est une défense simple contre les champs comme role, emailVerified ou 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>;
Le nom d’utilisateur est limité car il apparaît dans les URL publiques. Le nom affiché est rendu comme texte, pas comme HTML. Les liens sociaux peuvent être vides, mais les valeurs remplies doivent être des URL http ou https.
Route API de profil
L’endpoint doit lire la session côté serveur et ignorer tout propriétaire envoyé par le client. La sauvegarde du profil et l’écriture de l’audit sont placées dans une transaction Prisma.
// 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 réponse ne contient que les champs sûrs. Le journal d’audit enregistre les noms des champs modifiés et un contexte minimal. Évitez de stocker l’ancienne bio complète, les anciens liens sociaux ou l’ancienne localisation dans les logs.
Upload d’avatar
L’upload d’avatar doit être limité et réencodé. L’exemple ci-dessous écrit dans public/uploads/avatars pour un test local. En production, remplacez cette partie par un stockage objet et ajoutez la suppression des anciens fichiers.
// 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 });
}
Pour aller plus loin, définissez les URL signées, l’analyse malware, l’invalidation CDN et la durée de conservation. Claude Code peut ensuite implémenter ces choix au lieu d’inventer une politique implicite.
Formulaire React
Le formulaire sépare l’upload de l’avatar et la sauvegarde JSON du profil. Cela rend les erreurs plus faciles à expliquer à l’utilisateur et plus simples à tester.
// 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>
);
}
L’état local est corrigé avec la réponse serveur après sauvegarde. C’est important pour les champs visibles publiquement, comme username, isPublic ou avatarUrl.
Prompt Claude Code et revue
Donnez à Claude Code une demande précise, puis une passe de revue critique.
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
La partie la plus utile est la liste des interdits. Elle empêche Claude Code de traiter le profil comme une simple mise à jour générique d’objet utilisateur.
Cas d’utilisation
Premier cas: profil de compte SaaS. L’utilisateur modifie son nom, son département, son avatar et sa bio. Le plan et le rôle restent dans des flux séparés.
Deuxième cas: gestion d’équipe. Un administrateur peut consulter les profils, mais les changements de rôle passent par une API admin. Une édition déléguée doit distinguer acteur et cible dans l’audit.
Troisième cas: onboarding. Le nom d’utilisateur et le nom affiché peuvent être demandés au premier accès. La vérification email et l’acceptation des conditions restent séparées.
Quatrième cas: page publique. Seuls les profils isPublic sont visibles sur /users/[username]. La page publique ne doit pas renvoyer email, ID interne, données d’équipe ou logs.
Pièges fréquents
Le premier piège est d’accepter un ID utilisateur cible depuis la requête. Pour un profil en libre-service, la cible vient de la session.
Le deuxième piège est de passer ...body à Prisma. Cela crée un risque de mass assignment dès que le modèle évolue.
Le troisième piège est de faire confiance au navigateur pour les fichiers. Le serveur doit limiter, inspecter et réencoder.
Le quatrième piège est de journaliser trop de PII. L’audit doit expliquer qui a changé quoi et quand, pas dupliquer tout le profil.
Le cinquième piège est l’optimisme d’interface. Si le serveur rejette la sauvegarde, l’interface ne doit pas afficher un état publié ou réussi.
Monétisation et exploitation
Les profils influencent la conversion. Dans un SaaS, ils rendent les invitations d’équipe plus fiables. Dans une communauté, ils renforcent la crédibilité des auteurs. Dans une offre de formation, ils aident les visiteurs à demander un accompagnement.
ClaudeCodeLab accompagne les équipes sur ces surfaces petites mais sensibles: authentification, profils, écrans admin, audit et réglages proches de la facturation. Pour ajouter ce type de fonctionnalité à un produit existant ou former votre équipe à la revue Claude Code, consultez formation et conseil Claude Code.
Après le lancement, suivez le taux de complétion, les erreurs d’upload, les doublons de nom d’utilisateur, les vues de profils publics et les clics CTA. Les logs servent au support et aux incidents, pas au stockage secondaire de données personnelles.
Résultat testé
Masa a testé ce flux dans une petite application Next.js. La version qui commençait par les limites de données et les champs interdits était beaucoup plus simple à relire. La version demandée comme “belle page de profil” transportait userId dans l’état du formulaire, validait l’image seulement côté client et écrivait l’ancienne bio entière dans l’audit.
Avant publication, testez un userId différent dans le JSON, des champs role et emailVerified, une image de plus de 2MB, un lien javascript:, un username dupliqué et un échec de sauvegarde. Claude Code donne de la vitesse, mais la qualité finale vient de la vérification de la propriété, de la validation, des logs et de l’exposition publique.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.