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

Claude Codeでプロフィール編集APIが他人を書き換える事故を防ぐ設計

Claude Codeでユーザープロフィール機能を作ると起きるmass assignmentや画像アップロード事故を、僕の失敗込みで防ぐ実装手順。

Claude Codeでプロフィール編集APIが他人を書き換える事故を防ぐ設計

「プロフィール画面、いい感じに作っといて」

そうClaude Codeに頼んだら、30分で動く編集フォームが出てきました。表示名もアバターも保存できる。見た目は完璧。でも差分を読んで僕は固まりました。userId をリクエスト本文から受け取って、そのままPrismaに渡していたんです。

つまり、リクエストを少し細工すれば他人のプロフィールを書き換えられるAPI。動くけど、出してはいけないやつでした。

プロフィールは「名前と画像を保存するだけ」に見えて、本人確認・公開範囲・画像検証・履歴がぜんぶ絡む、地味に事故りやすい機能です。今日はそこを、僕がやらかした失敗込みで、安全に作る順番を書きます。

この記事の要点

  • プロフィール更新の対象は常に session.user.id に固定。本文の userId は絶対に信じない。
  • 入力はZodの .strict() で絞る。roleemailVerified が紛れ込んでも処理に入れない(mass assignment対策)。
  • User(ログイン・課金)とProfile(公開・編集情報)をテーブルで分け、権限カラムをプロフィール側に置かない。
  • アバターはMIME・サイズ・ピクセル数を検証し、Sharpで再エンコードしてから保存する。
  • Claude Codeには「やること」より「やってはいけないこと」を渡すと、危ない実装が減る。

スタックはNext.js App Router、Prisma、Zod、React。認証そのものの考え方はClaude Code認証実装ガイド、入力検証の基礎はClaude Codeフォームバリデーションに分けて書いたので、足りなければそちらも。

作る機能と、絶対に守る境界線

今回作るのは、ログイン中のユーザーが自分のプロフィールを編集して、必要なら公開ページにも出せる機能です。プロフィールに持たせるのは、表示名・ユーザー名・自己紹介・場所・Webサイト・SNSリンク・公開フラグ・アバター画像URL。

逆に、メールアドレス・課金状態・ロール・管理者権限は、プロフィール機能のに置きます。ここを混ぜないのが今日いちばん言いたいことです。

プロフィールは「自分をどう見せるか」を扱う場所であって、「その人が何をできるか」を決める場所じゃない。認証はログイン済みかを確かめる仕組み、認可はその操作をしていいかを判断する仕組み。受付で本人確認するのが認証、入っていい部屋を決めるのが認可、くらいの距離感です。

Claude Codeに仕様を渡すときは、この境界を表で明文化します。文章でふわっと伝えるより、表のほうが守られます。

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

全体の流れはこうです。入力はZodで絞り、認可を通し、保存と監査ログを1つのトランザクションにまとめる。画像だけは別ルートで検証して再エンコードする。

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設計でプロフィールと権限を物理的に引き離す

設計の段階で勝負はだいぶ決まります。UserProfile を一対一にして、変更履歴は ProfileAuditLog に逃がす。User にはログインや課金に関わる項目、Profile には公開・編集される表示情報だけ。username は公開URL(/users/masa みたいな形)に使うので、ユニーク制約を付けておきます。

// 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には「プロフィールテーブルに権限・課金・メール検証状態を追加しない」と一文添えておくと、頼んでもいないカラムが勝手に増えるのを防げます。schema設計まわりをもっと詰めたい人はClaude CodeでPrisma ORMを実装するも参考にどうぞ。公式のPrisma relationsも一度通しておくと、リレーションの貼り方で迷わなくなります。

Zodの .strict() で余計な値を玄関で止める

Zodは、TypeScriptで入力値の形をチェックするライブラリです。ここで .strict() を使うのがキモ。スキーマに無いキーを問答無用で弾きます。

これがあると、攻撃者が {"role":"admin"}{"emailVerified":true} を混ぜて送ってきても、そもそも更新処理に入りません。バリデーションの段階で400で返って終わり。

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

ポイントを3つ。表示名はHTMLじゃなく素のテキストとして表示する前提にする。ユーザー名はURLに出るので、最初から小文字・数字・アンダースコア・ハイフンだけに絞る。SNSリンクは空文字はOKにしつつ、値が入っているときは httphttps のURLだけ通す。javascript: から始まる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の過去の値までは保存しない。監査ログは便利なんですが、個人情報をためすぎると、今度はそのログ自体が漏れたら困る資産になります。便利と危険は紙一重。Route Handlerの作法は公式のNext.js Route Handlersが一次資料です。

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

画像アップロードは、プロフィール機能の中でいちばん事故が多いゾーンです。拡張子だけ見る、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 は申告制なので100%は信用できません。でも最初のふるいとしては効く。本命はSharpでの再エンコードで、ここを通すと元ファイルの形式や埋め込みメタデータに引きずられにくくなります。本番ではこれに加えて、アップロード後のウイルススキャン、署名付きURL、古い画像の削除、CDNキャッシュの扱いまで設計に入れます。画像検証だけで一本書けるくらい深いので、詳しくはClaude Codeでファイルアップロードを安全に実装するに分けました。判断の根拠はOWASP File Upload Cheat Sheetが参考になります。

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

クライアント側のフォームでは、保存前の状態と、サーバーが受理した状態をきっちり分けます。保存成功時はサーバーのレスポンスで状態を上書き、失敗時はエラーを見せる。楽観更新を使うにしても、表示名や公開フラグみたいに他画面へ波及する項目は、最後はサーバーの結果で必ず上書きします。

// 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に任せるとき、僕は依頼とレビューを2通に分けます。最初の依頼で「編集していいファイル・使うライブラリ・守る境界」を指定し、レビュー依頼で「セキュリティ観点でわざと厳しく自分の差分を見ろ」と命じる。

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

大事なのは「何を作るか」だけじゃなく「何をしてはいけないか」を渡している点です。userIdroleemailVerified を本文から受け取らない、という条件は、実装後のレビューでもしつこく確認する。冒頭の僕の事故は、まさにこの一文を最初に書いていなかったから起きました。Claude Codeがセキュリティでどう転びがちかはClaude Codeセキュリティ失敗事例7選にまとめてあります。

ユースケースで設計を当てにいく

設計が正しいかは、具体的な場面に当ててみると分かります。

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

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

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

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

僕がやらかした落とし穴5つ

正直に書きます。一発で安全に作れたことはありません。

落とし穴1は冒頭の他人のプロフィールを更新できるAPIPUT /api/profile?id=targetUserId みたいに対象をクエリや本文で受け取ると、認可チェックの漏れがそのまま事故になる。対象は常に session.user.id に固定、これだけは譲らない。

落とし穴2は、メールや権限を「ついで」に保存したこと。...body をそのままPrismaに渡す書き方だと、Zodにない値まで保存される。OWASPの言うmass assignmentは、この「便利な一括代入」が原因です。

落とし穴3は、画像の制限不足。サイズ上限なしでメモリを圧迫させ、MIMEも再エンコードもなしで、画像のふりをしたファイルを公開領域に置きかけた。accept 属性はUXの補助であって、防御じゃありません。

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

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

公開前に必ず通す確認

運用面も少しだけ。プロフィールは単なる設定画面じゃなくて、SaaSなら信頼感、チームツールなら誰が作業しているかの可視性、コミュニティなら投稿者の信用に直結します。雑だと、問い合わせ前の離脱やチーム招待後の混乱が増える。

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

よくある質問

Q. なぜ userId をリクエスト本文から受け取ってはいけないの? A. 本文の値は誰でも書き換えられるからです。userId を信じると、別人のIDを送るだけで他人のプロフィールを更新できてしまう。所有者の判定は、必ずサーバー側のセッション(session.user.id)だけを根拠にします。

Q. Zodの .strict().passthrough()、どっちを使う? A. プロフィール更新では .strict() 一択です。.passthrough() は未知のキーをそのまま通すので、role のような余計な値が紛れ込む余地を作ってしまう。「知らないキーは即エラー」が安全側です。

Q. File.type のチェックだけで画像は安全? A. いいえ。File.type はクライアントの申告なので偽装できます。最初のふるいとしては使いますが、本命はSharpでの再エンコードと、サイズ・ピクセル数の上限。この3つをセットにして初めて防御になります。

Q. 監査ログには何を残せばいい? A. 変更された項目名・操作した人(actor)・対象ユーザー・時刻・最小限のメタ情報まで。自己紹介の全文やURLの過去値は残さない。ログは便利な反面、個人情報をためすぎると漏れたとき困る資産に変わります。

Q. Claude Codeに任せるとき、いちばん効く一言は? A. 「やってはいけないこと」を先に渡すことです。userIdemailroleemailVerified を本文から受け取るな、と最初に明記するだけで、危ない実装がかなり減ります。レビュー依頼を別便で出すのも効きます。

実際に試した結果

小さなNext.js検証アプリで両方のやり方を試しました。先にDB境界と禁止項目を書いてからClaude Codeに頼んだ場合は、差分レビューが拍子抜けするほど楽。逆に「プロフィール画面をいい感じに作って」だけで頼んだ試作では、userId をフォーム状態に含める・画像のサイズ制限がUI側だけ・監査ログに変更前の自己紹介を丸ごと残す、という直しが一気に出ました。

公開前のチェックでは、別ユーザーIDを本文に混ぜても更新対象が変わらないこと、roleemailVerified を送ると400になること、2MB超の画像が拒否されること、javascript: URLがSNSリンクに保存されないこと、保存失敗時にフォームが成功表示にならないこと、をひとつずつ確認しました。

Claude Codeは実装を速くしてくれます。でもプロフィールみたいに個人情報を触る機能は、仕様・コード・ログ・公開表示を一つずつ目で追う最後のひと手間が品質を決めます。賢いAIに任せきるより、危ない入口を先に塞ぐ。これがいちばん速い、というのが今の実感です。

同じ「小さく見えて事故りやすい機能」を安全に作りたいときは、Claude Code研修・相談で実装方針から一緒に整理できます。

#Claude Code #ユーザープロフィール #認可 #画像アップロード #セキュリティ
無料

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

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

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

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

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

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

PR

関連書籍・参考図書

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

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