Build a Secure User Profile Feature with Claude Code
Implement user profiles with Claude Code: database design, forms, validation, authorization, avatars, and audit logs.
A user profile feature looks harmless until it reaches production. A display name and avatar can quickly turn into ownership checks, public and private fields, image upload limits, validation, account identity, audit logs, and confusing UI states. If you let Claude Code start from a vague request like “add profiles”, you may get a working screen but still miss the rules that keep one user from editing another user’s data.
This guide shows a practical way to use Claude Code to implement a secure profile feature in a Next.js App Router application. The examples use Prisma for the database, Zod for validation, React for the form, Sharp for avatar processing, and a small audit log table for traceability. The important lesson is not just the code; it is the contract you give Claude Code before it edits the repo.
For adjacent foundations, pair this with the Claude Code authentication implementation guide and the form validation guide. For official security context, read the OWASP Mass Assignment Cheat Sheet, the OWASP File Upload Cheat Sheet, Next.js Route Handlers, and Prisma relations.
Scope and Security Boundaries
The feature in this article lets a signed-in user edit their own profile and optionally publish it. The editable fields are username, display name, bio, location, website URL, social links, public visibility, and avatar URL. Email, billing plan, role, team membership, and email verification status stay outside this feature.
That separation matters. Authentication means proving who the user is. Authorization means deciding what the authenticated user may do. A profile screen should control how the user appears, not whether the user is an admin or whether their subscription is active. When beginners mix these ideas, the profile endpoint becomes a back door into account state.
Define the rules before asking Claude Code to edit:
| Area | Rule | Why it matters |
|---|---|---|
| Owner | Update only session.user.id | Prevents editing another user’s profile |
| Input | Accept only Zod-approved fields | Prevents mass assignment |
| Public response | Select only public-safe fields | Keeps email and internal IDs out |
| Avatar | Validate MIME, size, pixels, then re-encode | Reduces unsafe upload risk |
| Audit log | Store changed field names and minimal metadata | Keeps traceability without over-logging 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
Database Design
Use a one-to-one relation between User and Profile, and keep profile audit rows in a separate table. The profile table stores public-facing presentation data. The user table stores identity and account data. This keeps the profile update route from accidentally changing roles, plans, or verified email state.
// 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])
}
Ask Claude Code not to add role, plan, isAdmin, or emailVerified to Profile. If an administrator later needs to edit a member’s profile, build a separate admin endpoint with a separate authorization check and write the acting admin into actorUserId.
Zod Validation
Zod is the guardrail between untrusted request data and the database. The .strict() call is intentional: unknown keys should fail instead of being silently passed forward. That is the simplest way to keep role, email, or userId out of a generic profile update.
// 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>;
The display name is still text, not HTML. The username is intentionally narrow because it appears in URLs and public profile pages. Social links allow empty strings, but non-empty values must be http or https URLs. If the product later needs Mastodon, Bluesky, or regional social links, add them explicitly instead of accepting an arbitrary object.
Profile API Route
The route handler should authenticate on the server, ignore any owner identifier from the request body, and use session.user.id as the only write target. It should also select the fields it returns. This is boring code, and that is a good thing: the safer version is explicit.
// 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 });
}
Notice the audit log does not store the full previous bio, old social URLs, or old location. Logs are useful for investigations, but they can become another data exposure surface. Start with changed field names, actor, target, time, and minimal metadata. Add richer logging only when there is a clear support or compliance reason.
Avatar Upload
Image upload is where many profile implementations become risky. A browser accept attribute improves the picker experience, but it is not a security control. Check the MIME type, byte size, image dimensions, and then re-encode the image. The example below stores files under public/uploads/avatars for easy local testing. In production, replace the write step with S3, R2, GCS, or another object store.
// 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 });
}
For production, also decide how old avatars are deleted, whether uploads need malware scanning, how signed URLs are generated, and how CDN cache invalidation works. Claude Code can implement these details, but only after the storage policy is written down.
React Profile Form
The form keeps avatar upload separate from profile JSON updates. That makes the failure modes easier to reason about: image upload can fail without losing text edits, and text validation can fail without forcing a new image upload. The form updates local state from the server response after save, which avoids the common optimistic UI problem where the page says “public” even though the server rejected the update.
// 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>
);
}
For a polished product, add field-level error rendering from Zod, a duplicate username message, image preview before upload, keyboard focus management, and tests around disabled states. Those are useful improvements, but they should build on the same server-side validation and authorization rules.
Claude Code Prompt and Review Pass
Claude Code performs better when the prompt names the files, boundaries, and review criteria. This is the kind of prompt I would use in an existing product:
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
Run a second Claude Code pass as a security review. Ask it to look for a user updating another user’s profile, trusting email or role from the request, accepting arbitrary image uploads, leaking PII in logs, returning private fields from a public endpoint, and UI states that claim success before the server accepts the change.
Use Cases
The first use case is a SaaS account profile. Users edit their name, department, avatar, and public bio. Billing plan and admin role remain in separate account tables. This is the most common place where mass assignment bugs appear because the profile screen is close to account settings.
The second use case is team management. A manager may view member profiles, but role changes should go through a separate admin workflow. If an admin edits a member profile, the audit log should record the admin as actorUserId and the member as userId.
The third use case is onboarding. New users can choose a username and display name before inviting teammates. Keep terms acceptance, email verification, and marketing consent as separate states. That avoids a profile save accidentally implying the user has completed all compliance steps.
The fourth use case is a public profile page. Only profiles with isPublic should render at paths such as /users/[username]. Public pages should not expose email, internal database IDs, private team information, or audit logs. The public profile route should have its own select, not reuse an internal admin query.
Pitfalls
Pitfall one is accepting a target user ID from the request. PUT /api/profile?userId=abc is convenient for tests, but it becomes dangerous if the authorization check is incomplete. A self-service profile endpoint should derive the owner from the session only.
Pitfall two is passing ...body into Prisma. That creates a mass assignment risk. Even if the current Prisma model does not include sensitive fields, a future migration might add one. Use a strict schema and pass only parsed values.
Pitfall three is treating the browser file picker as validation. Attackers can bypass it. Check bytes, MIME type, image dimensions, and re-encode the file. Also avoid predictable filenames that let users overwrite each other’s avatars.
Pitfall four is logging too much personal data. Full bios, old social URLs, and locations may become sensitive. Audit logs should help answer “who changed what and when” without becoming a second copy of the profile database.
Pitfall five is optimistic UI drift. If the client immediately shows “profile is public” but the server rejects the username or URL, users lose trust. For profile fields that affect public pages, update the UI from the server response.
Monetization and Operations
Profiles affect revenue more than they appear to. In SaaS, a complete team profile makes invitations and collaboration feel trustworthy. In a marketplace or community, public profiles affect conversion. In training businesses, instructor and learner profiles influence whether people ask for a consultation.
ClaudeCodeLab helps teams implement these small but risk-heavy product surfaces with Claude Code: authentication, profiles, billing-adjacent settings, admin review screens, and audit workflows. If you want to introduce a profile feature into an existing product or teach your team how to review Claude Code output, see Claude Code training and consultation.
After launch, track profile completion rate, avatar upload failures, public profile views, duplicate username errors, and CTA clicks from public profiles. Use audit logs for support and incident review, but keep personal details out of event names and log messages.
Tested Result Note
Masa tested this flow in a small Next.js application. The version that started with database boundaries and forbidden fields was much easier to review. The version that started from “make a nice profile page” produced a form state that carried userId, image validation that existed only in the browser, and an audit log that stored full previous bios. Those are exactly the mistakes this workflow is meant to prevent.
Before publishing, test these cases: send a different userId in the JSON body, send role and emailVerified, upload an image larger than 2MB, submit a javascript: social URL, try a duplicate username, and force a failed save while the UI is pending. Claude Code can move quickly, but profile quality still depends on explicit ownership, validation, logging, and public-display checks.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.