Use Cases (Aktualisiert: 2.6.2026)

Sichere Benutzerprofile mit Claude Code implementieren

Profile mit Datenmodell, Formular, Validierung, Autorisierung, Avatar-Upload und Audit-Logs umsetzen.

Sichere Benutzerprofile mit Claude Code implementieren

Ein Benutzerprofil wirkt zunächst klein: Anzeigename, Kurzbeschreibung und Avatar. In einem echten Produkt geht es aber schnell um Eigentum an Daten, öffentliche und private Felder, Upload-Limits, Validierung, Autorisierung und Audit-Logs. Wenn Claude Code nur den Auftrag “baue eine Profilseite” bekommt, entsteht oft eine hübsche Oberfläche, aber die Sicherheitsgrenzen bleiben unscharf.

Dieser Leitfaden zeigt, wie du Claude Code für eine robuste Profilfunktion einsetzt. Die Beispiele nutzen Next.js App Router, Prisma, Zod, React und Sharp. Entscheidend ist nicht nur der Code, sondern der Vertrag vor dem Code: Welche Felder darf der Benutzer ändern, welche Felder dürfen niemals aus dem Request übernommen werden, und welche Daten dürfen öffentlich zurückgegeben werden?

Für die Grundlagen lies zusätzlich Claude Code Authentication und Claude Code Form Validation. Offizielle Referenzen sind OWASP Mass Assignment Cheat Sheet, OWASP File Upload Cheat Sheet, Next.js Route Handlers und Prisma relations.

Umfang und Sicherheitsgrenzen

Die Funktion erlaubt angemeldeten Benutzern, ihr eigenes Profil zu bearbeiten und es optional öffentlich sichtbar zu machen. Bearbeitet werden Benutzername, Anzeigename, Bio, Standort, Website, Social Links, öffentliche Sichtbarkeit und Avatar. E-Mail, Rolle, Plan, Teamrechte und E-Mail-Verifizierung gehören nicht in diese Funktion.

Authentifizierung beantwortet die Frage, wer der Benutzer ist. Autorisierung beantwortet, welche Aktion erlaubt ist. Ein Profil-Endpunkt darf daher nicht gleichzeitig Kontorollen, Zahlungsstatus oder Teamrechte ändern.

BereichRegelZweck
EigentümerNur session.user.id aktualisierenVerhindert Änderungen an fremden Profilen
EingabeNur von Zod erlaubte Felder akzeptierenVerhindert Mass Assignment
AntwortNur öffentlich sichere Felder auswählenSchützt E-Mail und interne IDs
AvatarMIME, Größe, Pixel prüfen und neu encodierenReduziert Upload-Risiken
AuditFeldnamen und minimale Metadaten speichernNachvollziehbarkeit ohne PII-Kopie
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

Datenmodell

Trenne User und Profile als 1:1-Beziehung. User enthält Identität und Konto-Status. Profile enthält nur Darstellungsdaten. Änderungen werden in ProfileAuditLog protokolliert.

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

Füge role, plan, isAdmin oder emailVerified nicht zu Profile hinzu. Wenn Admins später Profile bearbeiten dürfen, sollte das über einen separaten Admin-Endpunkt mit eigenem Autorisierungscheck laufen.

Zod-Validierung

Zod ist die Grenze zwischen untrusted Input und Datenbank. .strict() ist hier Absicht: unbekannte Felder führen zu einem Fehler. Dadurch werden Payloads mit role, emailVerified oder userId nicht versehentlich gespeichert.

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

Der Benutzername ist eng begrenzt, weil er in öffentlichen URLs landet. Der Anzeigename ist Text, kein HTML. Social Links dürfen leer sein, müssen aber bei Inhalt http oder https verwenden.

API-Route für Profilupdates

Der Endpunkt liest die Session serverseitig und nutzt ausschließlich session.user.id als Ziel. Ein userId aus dem Request Body wird nicht akzeptiert. Profiländerung und Audit-Log laufen in einer Transaktion.

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

Die Antwort nutzt einen expliziten select. Sie enthält keine E-Mail, keine Rolle, keinen Plan und keine Audit-Daten. Das Audit-Log speichert Feldnamen, Akteur, Ziel und minimale Metadaten, aber keine komplette frühere Bio.

Avatar-Upload

Beim Avatar-Upload reicht ein Dateipicker nicht aus. Der Server muss Typ, Größe und Abmessungen prüfen und die Datei neu encodieren. Das Beispiel speichert lokal in public/uploads/avatars; für Produktion ersetzt du diesen Teil durch Object Storage.

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

In Produktion brauchst du zusätzlich eine Strategie für alte Avatare, signierte URLs, Malware-Scan, CDN-Cache und Speicherfristen.

React-Formular

Das Formular trennt Avatar-Upload und Profil-JSON. Dadurch sind Fehler leichter zu behandeln. Nach erfolgreichem Speichern wird der Zustand aus der Serverantwort übernommen, nicht aus einer optimistischen Annahme.

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

Feldfehler, doppelte Benutzernamen, Avatar-Vorschau und Fokusführung sind sinnvolle Ergänzungen. Sie ersetzen aber nie die serverseitige Prüfung.

Prompt und Review mit Claude Code

Gib Claude Code nicht nur die gewünschte Funktion, sondern auch die verbotenen Wege.

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

Der zweite Review-Durchlauf sollte ausdrücklich nach Mass Assignment, fehlender Autorisierung, unsicherem Upload, PII in Logs und optimistischen UI-Fehlern suchen.

Use Cases

Erster Use Case: SaaS-Kontoprofil. Benutzer ändern Name, Abteilung, Avatar und Bio. Plan und Rolle bleiben in getrennten Abläufen.

Zweiter Use Case: Teamverwaltung. Admins können Profile sehen, aber Rollenänderungen verwenden einen eigenen Admin-Endpunkt. Delegierte Änderungen müssen Akteur und Ziel im Audit trennen.

Dritter Use Case: Onboarding. Benutzername und Anzeigename werden beim ersten Login abgefragt. E-Mail-Verifizierung und Zustimmung zu Bedingungen bleiben separate Zustände.

Vierter Use Case: öffentliches Profil. Nur isPublic-Profile erscheinen unter /users/[username]. Die öffentliche Seite gibt keine E-Mail, internen IDs, Teamdaten oder Logs zurück.

Typische Fehler

Fehler eins ist ein Ziel-userId aus dem Request. Ein Self-Service-Profil muss die Zielperson aus der Session ableiten.

Fehler zwei ist ...body direkt in Prisma. Das ist bequem, aber gefährlich, sobald das Modell wächst.

Fehler drei ist reines Frontend-Upload-Filtering. Der Server muss prüfen und neu encodieren.

Fehler vier ist zu viel PII in Logs. Ein Audit-Log soll Änderungen nachvollziehbar machen, nicht das Profil duplizieren.

Fehler fünf ist UI-Optimismus ohne Korrektur. Öffentliche Felder müssen aus der Serverantwort aktualisiert werden.

Monetarisierung und Betrieb

Profile wirken auf Umsatz. In SaaS-Produkten erhöhen vollständige Teamprofile Vertrauen. In Communities beeinflussen öffentliche Profile Glaubwürdigkeit. In Trainingsangeboten helfen Profile von Trainern und Teilnehmern bei der Entscheidung für Beratung.

ClaudeCodeLab unterstützt Teams bei solchen kleinen, aber riskanten Oberflächen: Authentifizierung, Profile, Admin-Screens, Audit-Logs und Einstellungen nahe an Billing. Wenn du Profile sicher in ein bestehendes Produkt einbauen oder dein Team im Review von Claude-Code-Änderungen trainieren willst, siehe Claude Code Training und Beratung.

Nach dem Launch solltest du Profilvollständigkeit, Avatar-Fehler, doppelte Usernames, öffentliche Profilaufrufe und CTA-Klicks messen. Logs sind für Support und Incidents da, nicht als zweiter Speicher für persönliche Daten.

Getestetes Ergebnis

Masa hat diesen Ablauf in einer kleinen Next.js-App geprüft. Die Version mit vorher definierten Datenbankgrenzen und verbotenen Feldern war deutlich leichter zu reviewen. Die Version mit der vagen Bitte nach einer schönen Profilseite enthielt userId im Formularzustand, Bildvalidierung nur im Client und vollständige alte Bios im Audit.

Vor der Veröffentlichung teste: fremde userId im JSON, role und emailVerified, Bilder über 2MB, javascript:-Links, doppelte Usernames und fehlgeschlagene Saves. Claude Code bringt Tempo, aber Profilqualität entsteht durch klare Ownership, Validierung, Logs und öffentliche Feldkontrolle.

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

Kostenloses PDF: Claude-Code-Cheatsheet

E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.

Wir schützen Ihre Daten und senden keinen Spam.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.