Use Cases (Mis à jour: 02/06/2026)

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.

Créer un profil utilisateur sécurisé 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.

SujetRèglePourquoi
PropriétaireMettre à jour uniquement session.user.idEmpêche de modifier un autre profil
EntréeAccepter seulement les champs validés par ZodÉvite le mass assignment
RéponseSélectionner les champs publics sûrsNe divulgue pas email ni ID interne
AvatarVérifier MIME, taille, pixels, puis réencoderRéduit le risque d’upload dangereux
AuditStocker les champs modifiés et peu de métadonnéesGarde 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.

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

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.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.