Use Cases (更新: 2026/6/2)

Claude Codeでユーザープロフィール機能を実装する完全ガイド

DB設計、フォーム、認可、画像、監査ログまでClaude Codeでプロフィール機能を安全に作る実装ガイド。

Claude Codeでユーザープロフィール機能を実装する完全ガイド

ユーザープロフィールは、名前と画像を保存するだけの小さな機能に見えます。実際には、本人だけが更新できること、公開してよい項目だけを外に出すこと、画像アップロードで危険なファイルを受け付けないこと、変更履歴を追えることまで含めて設計しないと、あとから修正が難しくなります。

Claude Codeに「プロフィール画面を作って」とだけ頼むと、見た目は早くできます。しかし、userId をリクエスト本文から受け取る、メールアドレスや権限をプロフィール更新APIで書き換えられる、画像サイズの上限がない、監査ログに個人情報を丸ごと残す、といった事故が入り込みやすくなります。初心者ほど、最初に境界線を文章で決めてから実装させるのが安全です。

この記事では、Next.js App Router、Prisma、Zod、Reactを例に、Claude Codeでユーザープロフィール機能を作る手順をまとめます。関連する認証の考え方はClaude Code認証実装ガイド、フォーム入力の基本はClaude Codeフォームバリデーションも合わせて確認してください。外部資料としては、OWASP Mass Assignment Cheat SheetOWASP File Upload Cheat SheetNext.js Route HandlersPrisma relationsを参照します。

作る機能と守る境界

今回作るのは、ログインユーザーが自分のプロフィールを編集し、必要に応じて公開プロフィールとして表示できる機能です。プロフィールには表示名、ユーザー名、自己紹介、場所、Webサイト、SNSリンク、公開フラグ、アバター画像URLを持たせます。メールアドレス、課金状態、ロール、管理者権限はプロフィール機能の外に置きます。

この切り分けが重要です。プロフィールは「自分をどう見せるか」を扱う場所であり、「その人が誰か」「何をできるか」を決める場所ではありません。認証はログイン済みかを確認する仕組み、認可はそのユーザーがその操作をしてよいかを判断する仕組みです。初心者向けに言い換えると、認証は受付で本人確認をすること、認可はその人が入ってよい部屋を決めることです。

Claude Codeに渡す仕様では、次のように境界を明文化します。

観点採用するルール理由
所有者session.user.id だけを更新対象にする他人のプロフィール更新を防ぐ
入力項目Zodのスキーマにある項目だけ受け付けるmass assignmentを防ぐ
公開項目公開APIではメールや内部IDを返さない個人情報の漏えいを防ぐ
画像MIME、サイズ、ピクセル数を制限して再エンコードする不正ファイルと巨大画像を防ぐ
監査ログ変更した項目名と最小限のメタ情報だけ残す調査性とプライバシーを両立する
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

DB設計はプロフィールと認可情報を分ける

DB設計では、UserProfile を一対一にし、プロフィール変更の履歴を ProfileAuditLog に分けます。User にはログインや課金に関わる項目を置き、Profile には公開・編集される表示情報だけを置きます。username は公開URLに使うためユニーク制約を付けます。

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

ここで Profileroleplan を入れないのがポイントです。将来、チーム管理や管理画面を追加するときも、プロフィール編集APIから権限を変えられない構造になります。Claude Codeには「プロフィールテーブルに権限・課金・メール検証状態を追加しない」と明示しておくと、余計なカラムが増えにくくなります。

Zodで入力を絞り込む

Zodは、TypeScriptで入力値の形を検証するライブラリです。ここでは未知のキーを拒否する .strict() を使います。これにより、攻撃者が {"role":"admin"}{"emailVerified":true} のような余計な値を送っても、プロフィール更新処理に入りません。

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

表示名はHTMLとして扱わず、通常のテキストとして表示します。ユーザー名はURLに出るため、最初から小文字・数字・アンダースコア・ハイフンに限定します。SNSリンクは空文字を許可しつつ、入っている場合は http または https のURLだけにします。

更新APIは本人のプロフィールだけを触る

Next.jsのRoute Handlerでは、必ずサーバー側でセッションを取得し、更新対象を session.user.id に固定します。リクエスト本文に userId が入っていても使いません。さらに、プロフィール保存と監査ログ保存を1つのトランザクションにまとめます。

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

このコードは、公開してよい項目だけを select しています。内部IDやメールアドレスは返しません。changedFields には変更されたフィールド名を残しますが、自己紹介文の全文やSNS URLの過去値をすべて残す設計にはしていません。監査ログは便利ですが、個人情報をためすぎると別のリスクになります。

アバター画像は制限して再エンコードする

画像アップロードはプロフィール機能の中で特に事故が起きやすい部分です。拡張子だけを見る、accept="image/*" だけに頼る、巨大画像をそのままSharpに渡す、アップロード先を公開ディレクトリに固定ファイル名で保存する、といった実装は避けます。

以下は開発環境でも動かしやすいように、public/uploads/avatars にWebPとして保存する例です。本番ではS3やR2などのオブジェクトストレージに置き換えられますが、検証の順番は同じです。

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

File.type は絶対ではありませんが、最初のふるいとして有効です。Sharpで再エンコードすると、元ファイルの形式やメタデータに引きずられにくくなります。さらに本番では、アップロード後のウイルススキャン、署名付きURL、古い画像の削除、CDNキャッシュの扱いも設計に入れます。

フォームは楽観更新より整合性を優先する

プロフィールフォームでは、保存前のクライアント状態と、サーバーが受理した状態を分けます。保存成功時にサーバーのレスポンスで状態を更新し、失敗時はエラーを見せます。楽観更新を使う場合でも、表示名や公開フラグのように他画面へ影響する項目では、サーバーの結果で必ず上書きします。

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

このフォームは、アバターとプロフィール本体を別APIにしています。理由は、画像は multipart/form-data、プロフィール本文はJSONで扱うほうが検証しやすいからです。1つの巨大なAPIにまとめると、失敗時の扱い、リトライ、監査ログ、テストが複雑になります。

Claude Codeへの依頼文とレビュー指示

Claude Codeには、実装依頼とレビュー依頼を分けて渡すのが実用的です。最初の依頼では編集してよいファイル、使うライブラリ、守るべき境界を指定します。レビュー依頼では、セキュリティ観点であえて厳しく見てもらいます。

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

この依頼文で大事なのは、Claude Codeに「何を作るか」だけでなく「何をしてはいけないか」を渡している点です。特に userIdroleemailVerified を本文から受け取らないという条件は、実装後のレビューでも繰り返し確認します。

ユースケースは3つ以上で設計を検証する

1つ目は、SaaSのアカウント設定です。ユーザーは表示名、部署、アバターを編集します。請求プランや管理者権限は別画面で扱うため、プロフィールAPIから変更できてはいけません。監査ログは「誰が表示名を変えたか」を追うために役立ちます。

2つ目は、チーム管理です。管理者がメンバー一覧を見る場合でも、本人のプロフィール編集と管理者のロール変更はAPIを分けます。管理者画面からプロフィールを代理編集するなら、actorUserId は管理者、userId は対象ユーザーとして監査ログに残します。

3つ目は、オンボーディングです。初回ログイン後にユーザー名と表示名を登録させると、公開プロフィールや招待画面で名前を出せます。ただし、オンボーディング中でもメール検証や利用規約同意は別の状態として持ち、プロフィール保存と混ぜません。

4つ目は、公開プロフィールです。isPublic が true のプロフィールだけ、/users/[username] のようなページで表示します。公開ページでは内部ID、メール、監査ログ、非公開のSNSリンクを返さないようにします。プロフィール機能は見た目よりも、公開範囲の設計が品質を左右します。

失敗例と落とし穴

落とし穴1は、他人のプロフィールを更新できるAPIです。PUT /api/profile?id=targetUserId のように対象ユーザーをクエリや本文で受け取ると、認可チェックの漏れが事故になります。本人編集APIでは、対象は常に session.user.id に固定します。

落とし穴2は、メールや権限をプロフィール更新のついでに保存することです。...body をそのままPrismaに渡すと、Zodにない値まで保存される設計になりがちです。OWASPが説明するmass assignmentは、このような「便利な一括代入」が原因になります。

落とし穴3は、画像アップロードの制限不足です。サイズ上限がないとメモリを圧迫します。MIMEや再エンコードがないと、画像のふりをしたファイルを公開領域に置くリスクが上がります。accept 属性はユーザー体験の補助であり、防御ではありません。

落とし穴4は、PIIをログに出しすぎることです。自己紹介、所在地、過去のSNS URLを監査ログに全文保存すると、ログ基盤側のアクセス制御や保存期間まで問題になります。まずは変更項目名、操作主体、時刻、最小限のメタ情報に絞ります。

落とし穴5は、楽観更新の不整合です。保存前に画面だけ「公開中」と表示し、サーバーでバリデーション失敗しているのに状態を戻さないと、ユーザーは公開されたと誤解します。公開フラグ、ユーザー名、アバターURLは、サーバー応答を正として扱います。

収益導線と運用の作り込み

プロフィール機能は、単なる設定画面ではありません。SaaSでは信頼感、チームツールでは誰が作業しているかの可視性、コミュニティでは投稿者の信用、研修サービスでは講師や受講者の導線に直結します。プロフィールが雑だと、問い合わせ前の離脱やチーム招待後の混乱が増えます。

ClaudeCodeLabでは、プロフィール、認証、課金、管理画面のような「小さく見えて事故りやすい機能」をClaude Codeで実装する支援をしています。既存プロダクトに安全なプロフィール編集を入れたい場合や、チームにClaude Codeのレビュー手順を定着させたい場合は、Claude Code研修・相談で実装方針から一緒に整理できます。

運用では、プロフィール更新数、画像アップロード失敗率、公開プロフィールの閲覧数、CTAクリック率を追います。監査ログはセキュリティ調査だけでなく、「どの項目でユーザーがつまずくか」を見る材料にもなります。ただし、ログ分析に使う場合でも、個人情報を直接イベント名やログ本文に入れないようにします。

実際に試した結果

Masaが小さなNext.js検証アプリでこの流れを試したところ、最初にDB境界と禁止項目を書いてからClaude Codeに依頼した場合は、差分レビューがかなり楽になりました。逆に「プロフィール画面をいい感じに作って」とだけ頼んだ試作では、userId をフォーム状態に含める、画像アップロードでサイズ制限がUI側だけ、監査ログに変更前の自己紹介を丸ごと残す、という修正点が出ました。

公開前のチェックでは、別ユーザーIDを本文に混ぜても更新対象が変わらないこと、roleemailVerified を送ると400になること、2MB超の画像が拒否されること、javascript: URLがSNSリンクに保存されないこと、保存失敗時にフォームが成功表示にならないことを確認しました。Claude Codeは実装速度を上げてくれますが、プロフィールのような個人情報を扱う機能では、仕様、コード、ログ、公開表示を1つずつ確認する姿勢が最後の品質を決めます。

#Claude Code #user profile #authentication #security #React
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。