Sichere Benutzerprofile mit Claude Code implementieren
Profile mit Datenmodell, Formular, Validierung, Autorisierung, Avatar-Upload und Audit-Logs umsetzen.
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.
| Bereich | Regel | Zweck |
|---|---|---|
| Eigentümer | Nur session.user.id aktualisieren | Verhindert Änderungen an fremden Profilen |
| Eingabe | Nur von Zod erlaubte Felder akzeptieren | Verhindert Mass Assignment |
| Antwort | Nur öffentlich sichere Felder auswählen | Schützt E-Mail und interne IDs |
| Avatar | MIME, Größe, Pixel prüfen und neu encodieren | Reduziert Upload-Risiken |
| Audit | Feldnamen und minimale Metadaten speichern | Nachvollziehbarkeit 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.
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.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.