Claude Codeでファイルアップロードを安全に実装する方法
Claude CodeでSaaS向けファイルアップロードを安全に作る実装ガイド。検証、プレビュー、進捗、S3判断まで解説。
ファイルアップロードは、SaaSを作り始めるとかなり早い段階で出てくる機能です。プロフィール画像、見積書PDF、CSVインポート、本人確認書類、チャットの添付ファイル。見た目は「ファイルを選んで送るだけ」ですが、実装を雑にすると、巨大ファイルでサーバーが詰まる、危ない拡張子が保存される、画像プレビューでメモリを食う、S3の公開範囲を間違える、という事故につながります。
Claude Codeに頼むと、この手の実装はかなり速く作れます。ただし、プロンプトが「ファイルアップロード作って」だけだと、動くけれどレビューしづらいコードになりがちです。特に初心者がつまずくのは、ブラウザ側のFile API、送信に使うFormData、HTTP通信に使うfetch、サーバー側バリデーション、保存先の判断が一つの塊に見えてしまうところです。
この記事では、まずローカル保存の最小構成で「安全な入口」を作り、その後にS3やCloud Storageへ移す判断まで扱います。MDNのFile API、FormData、Fetch APIを前提にしつつ、Claude Codeへそのまま渡せるプロンプトと、コピペして動かしやすいコードを載せます。
関連して、S3へ移す段階の考え方はClaude CodeとAWS S3連携で、権限や危険操作の考え方はClaude Codeセキュリティ実践でも整理しています。
まずブラウザ側で何が起きているかを分ける
初心者向けに言うと、ファイルアップロードは三つの役割に分かれます。
一つ目はFile APIです。これは、ユーザーが<input type="file">で選んだファイルや、ドラッグ&ドロップしたファイルの情報をブラウザで扱うための仕組みです。file.name、file.size、file.type、file.lastModifiedのようなメタ情報を読めます。画像ならURL.createObjectURL(file)でプレビュー用URLを作れます。
二つ目はFormDataです。これはフォームの値とファイルを、multipart/form-data形式でサーバーへ送るための箱です。formData.append("file", file)のように入れて、fetch("/api/upload", { method: "POST", body: formData })で送ります。ここで大事なのは、FormDataを使うときに自分でContent-Typeヘッダーを固定しないことです。境界文字列をブラウザが付けるため、手で指定すると壊れることがあります。
三つ目はfetchです。fetchはHTTPリクエストを送る標準APIです。小さなファイルをフォーム送信するだけならfetchで十分です。ただし、2026年時点でもブラウザ標準のfetchだけではアップロード進捗イベントを扱いにくいので、進捗バーを確実に出したい場合はXMLHttpRequestを併用するのが現実的です。ここをClaude Codeに明示しないと、「進捗っぽい表示」だけ作られて実際の転送量とズレることがあります。
最小実装: まずは素のHTMLとfetchで流れを確認する
最初はReactもS3もいりません。1ファイルを選んで、ブラウザ側で軽く検証し、FormDataで送るところまでを作ると理解が早いです。
<form id="upload-form">
<input id="file-input" name="file" type="file" accept="image/png,image/jpeg,application/pdf" />
<button type="submit">アップロード</button>
</form>
<img id="preview" alt="" style="max-width: 240px; display: none;" />
<p id="message"></p>
<script type="module">
const MAX_BYTES = 5 * 1024 * 1024;
const allowedTypes = new Set(["image/png", "image/jpeg", "application/pdf"]);
const form = document.querySelector("#upload-form");
const input = document.querySelector("#file-input");
const preview = document.querySelector("#preview");
const message = document.querySelector("#message");
input.addEventListener("change", () => {
const file = input.files?.[0];
preview.style.display = "none";
preview.removeAttribute("src");
message.textContent = "";
if (!file) return;
if (!allowedTypes.has(file.type)) {
message.textContent = "PNG、JPEG、PDFだけアップロードできます。";
input.value = "";
return;
}
if (file.size > MAX_BYTES) {
message.textContent = "ファイルサイズは5MB以下にしてください。";
input.value = "";
return;
}
if (file.type.startsWith("image/")) {
preview.src = URL.createObjectURL(file);
preview.style.display = "block";
preview.onload = () => URL.revokeObjectURL(preview.src);
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = input.files?.[0];
if (!file) {
message.textContent = "ファイルを選択してください。";
return;
}
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData
});
const result = await response.json();
message.textContent = response.ok ? `保存しました: ${result.name}` : result.error;
});
</script>
このコードは、ファイル選択、MIMEタイプ確認、サイズ確認、画像プレビュー、FormData送信の最小セットです。Claude Codeに最初から豪華なUIを作らせるより、この形をベースに「この動きをReact化して」「サーバー側検証を追加して」と段階的に頼むほうが失敗しにくいです。
Reactでプレビューと進捗表示まで作る
React版では、状態管理を分けると読みやすくなります。selectedFile、previewUrl、progress、error、uploadedNameを分けておくと、Claude Codeが後から修正しやすいです。
進捗表示だけは注意が必要です。fetchでFormDataを送る実装はシンプルですが、アップロード進捗を細かく取得する標準的なイベントがありません。そこで、実務では「通常送信はfetch」「進捗が必要な画面はXMLHttpRequest」という使い分けがまだよくあります。
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "application/pdf"]);
type UploadResult = {
ok: true;
name: string;
size: number;
type: string;
};
export function FileUploadBox() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [uploadedName, setUploadedName] = useState<string | null>(null);
const canUpload = useMemo(() => selectedFile && !error, [selectedFile, error]);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] ?? null;
setUploadedName(null);
setProgress(0);
setError(null);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
if (!file) {
setSelectedFile(null);
return;
}
if (!ALLOWED_TYPES.has(file.type)) {
setSelectedFile(null);
setError("PNG、JPEG、PDFだけアップロードできます。");
return;
}
if (file.size > MAX_BYTES) {
setSelectedFile(null);
setError("ファイルサイズは5MB以下にしてください。");
return;
}
setSelectedFile(file);
if (file.type.startsWith("image/")) {
setPreviewUrl(URL.createObjectURL(file));
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedFile) return;
const formData = new FormData();
formData.append("file", selectedFile);
const result = await uploadWithProgress(formData, setProgress);
setUploadedName(result.name);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="file"
accept="image/png,image/jpeg,application/pdf"
onChange={handleFileChange}
/>
{previewUrl && <img src={previewUrl} alt="選択した画像のプレビュー" width={240} />}
{selectedFile && <p>{selectedFile.name} / {Math.round(selectedFile.size / 1024)}KB</p>}
{error && <p role="alert">{error}</p>}
<progress value={progress} max={100}>{progress}%</progress>
<button type="submit" disabled={!canUpload}>アップロード</button>
{uploadedName && <p>保存しました: {uploadedName}</p>}
</form>
);
}
function uploadWithProgress(
formData: FormData,
onProgress: (progress: number) => void
) {
return new Promise<UploadResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable) return;
onProgress(Math.round((event.loaded / event.total) * 100));
});
xhr.addEventListener("load", () => {
const body = JSON.parse(xhr.responseText || "{}");
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
else reject(new Error(body.error ?? "Upload failed"));
});
xhr.addEventListener("error", () => reject(new Error("Network error")));
xhr.send(formData);
});
}
この段階でのポイントは、ブラウザ側の検証を「親切なUI」として扱うことです。ユーザーに早くエラーを見せるためには必要ですが、セキュリティの最終防衛線ではありません。MIMEタイプ、拡張子、サイズは必ずサーバー側でも見ます。
サーバー側バリデーションは必ず二重に行う
次はNext.jsのRoute Handler例です。ローカルの.local-uploadsへ保存する最小構成にしています。実運用では、この保存先をS3、Cloudflare R2、Google Cloud Storage、Azure Blob Storageなどへ差し替えます。
// app/api/upload/route.ts
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
["image/png", ".png"],
["image/jpeg", ".jpg"],
["application/pdf", ".pdf"]
]);
export async function POST(request: NextRequest) {
const formData = await request.formData();
const value = formData.get("file");
if (!(value instanceof File)) {
return NextResponse.json({ error: "fileがありません。" }, { status: 400 });
}
const expectedExt = ALLOWED_TYPES.get(value.type);
const originalExt = path.extname(value.name).toLowerCase();
if (!expectedExt) {
return NextResponse.json({ error: "許可されていないMIMEタイプです。" }, { status: 400 });
}
if (value.size === 0 || value.size > MAX_BYTES) {
return NextResponse.json({ error: "ファイルサイズは1byte以上5MB以下にしてください。" }, { status: 400 });
}
if (expectedExt === ".jpg" && ![".jpg", ".jpeg"].includes(originalExt)) {
return NextResponse.json({ error: "JPEGの拡張子が不正です。" }, { status: 400 });
}
if (expectedExt !== ".jpg" && originalExt !== expectedExt) {
return NextResponse.json({ error: "MIMEタイプと拡張子が一致しません。" }, { status: 400 });
}
const bytes = Buffer.from(await value.arrayBuffer());
const safeExt = expectedExt === ".jpg" ? ".jpg" : expectedExt;
const storedName = `${randomUUID()}${safeExt}`;
const uploadDir = path.join(process.cwd(), ".local-uploads");
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, storedName), bytes, { flag: "wx" });
return NextResponse.json({
ok: true,
name: storedName,
size: value.size,
type: value.type
});
}
サーバー側で見るべき項目は、最低でも次の五つです。
| チェック | 理由 |
|---|---|
| MIMEタイプ | ブラウザやクライアントが送ってくるfile.typeを鵜呑みにしないため |
| 拡張子 | .jpgに見せた実行ファイルや、想定外ファイルの保存を避けるため |
| サイズ | メモリ、ディスク、転送量の事故を避けるため |
| 保存名 | 元ファイル名をそのまま使わず、パストラバーサルや上書きを避けるため |
| 保存場所 | 公開ディレクトリへ雑に置かず、用途ごとに分けるため |
本当に厳密にやるなら、MIMEタイプと拡張子だけでは足りません。画像ならマジックナンバーや画像デコード、PDFならヘッダー確認、ウイルススキャン、認証ユーザーとの紐付けも必要になります。Claude Codeには「MIMEと拡張子だけで安全と言い切らないで」と明示しておくと、記事やコメントの表現も安全寄りになります。
S3やCloud Storageへ移す判断
ローカル保存で十分なのは、開発環境、社内ツールの試作、数人だけが使う一時的な画面くらいです。SaaSでユーザーが継続的にファイルをアップロードするなら、早い段階でオブジェクトストレージへ移したほうがいいです。
判断基準はシンプルです。ファイルがユーザー資産である、サーバーを複数台にする可能性がある、バックアップやライフサイクル管理が必要、配信量が増える、画像変換を非同期にしたい。このどれかに当てはまるなら、S3、Cloud Storage、Azure Blob Storage、Cloudflare R2を検討します。
ただし、最初からブラウザからS3へ直接アップロードさせる必要はありません。小規模なら、アプリサーバーで受けてからストレージへ移すほうが実装と監査が簡単です。大きなファイルや高頻度アップロードになってから、署名付きURLやマルチパートアップロードを導入するほうが安全に進めやすいです。
Claude Codeへの依頼は、保存先ごとに分けます。
Next.jsのファイルアップロードをS3保存に変更してください。
前提: 現在は /api/upload で FormData を受け取り、MIME/拡張子/サイズ検証をしています。
制約: 元ファイル名をS3 keyに使わない。uploads/yyyy/mm/dd/{uuid}.ext に保存する。
制約: 許可タイプは image/png, image/jpeg, application/pdf のみ。最大5MB。
制約: S3 bucketをpublicにしない。返すのは保存IDだけ。公開URLは別APIで署名付きURLを発行する。
成果物: app/api/upload/route.ts、lib/storage/s3.ts、エラー時のテスト、READMEの環境変数説明。
確認: npm test と、5MB超過・拡張子不一致・未ログインのテスト観点も説明してください。
このプロンプトの良いところは、「作って」ではなく「現状」「制約」「成果物」「確認」を分けている点です。Claude Codeが勝手に公開URLを返したり、bucketをpublic前提にしたりする余地を減らせます。
3つの実例で設計を変える
実例1は、プロフィール画像です。ユーザー体験としてはプレビューが重要です。アップロード前に正方形へトリミングする、容量を軽くする、保存後にサムネイルを作る、といった処理が欲しくなります。セキュリティ面では、画像以外を拒否し、SVGを許可するかどうかを慎重に決めます。初心者向けには、まずPNG/JPEG/WebPだけに絞るのが安全です。
実例2は、管理画面のCSVインポートです。ここではプレビューよりも、取り込み前の検証が重要です。列名が合っているか、文字コードが想定通りか、行数が多すぎないか、取り込み結果をロールバックできるかを見ます。ファイルアップロード機能というより、「アップロード後の業務処理」が本体です。Claude CodeにはCSVのサンプル、失敗行の扱い、エラーレポート形式まで渡すと精度が上がります。
実例3は、請求書や契約書PDFです。これは公開URLを返してはいけない代表例です。保存先はprivate、ダウンロードは認証後に短時間の署名付きURL、ログには誰がいつアクセスしたかを残します。SaaSでマネタイズを考えるなら、この領域は信頼に直結します。画面が少し地味でも、権限設計と監査ログを優先すべきです。
実例4として、チャット添付もあります。画像、PDF、テキストなど種類が混ざり、送信直後の進捗表示やキャンセルが重要になります。大量に添付されるとコストも効いてくるため、保存期間や自動削除ルールも最初に決めておくと後で楽です。
よくある失敗例
一つ目の失敗は、ブラウザ側のaccept属性をセキュリティ対策だと思ってしまうことです。accept="image/png"は、ファイル選択ダイアログの補助であって、攻撃や不正リクエストを止める壁ではありません。サーバー側で必ず検証します。
二つ目は、元ファイル名をそのまま保存名に使うことです。日本語や空白が混ざるだけでも運用が面倒になりますし、同名ファイルの上書きやパス操作の原因になります。表示用の元ファイル名はDBへ保存し、実体の保存名はUUIDなどで作るほうが安全です。
三つ目は、アップロード直後に公開URLを返すことです。プロフィール画像のように公開前提ならまだよいですが、PDF、契約書、CSV、社内資料でこれをやると危険です。「保存した」と「公開した」は別の状態として扱います。
四つ目は、進捗表示の嘘です。fetchで送信しているのに、タイマーで0から100へ増やすだけのUIを作ると、ネットワークが遅いときにユーザーの信頼を失います。進捗が必要ならXHRを使う、または「アップロード中」とだけ表示するほうが誠実です。
五つ目は、S3の権限を広くしすぎることです。Claude Codeに「S3へアップロードできるようにして」とだけ頼むと、サンプルとして広い権限が出る場合があります。PutObjectだけ、対象prefixだけ、公開しない、削除しない、といった制約をプロンプトへ書きます。
Claude Codeに渡すコピペ用プロンプト
まずはローカル保存版です。
このNext.jsアプリに安全なファイルアップロードを追加してください。
目的: SaaSの管理画面で、PNG/JPEG/PDFを1ファイルずつアップロードする。
クライアント: ReactコンポーネントでFile APIを使い、選択ファイル名、サイズ、画像プレビュー、エラー、アップロード中状態を表示する。
送信: FormDataで /api/upload にPOSTする。進捗表示が必要な場合はfetchではなくXMLHttpRequestを使う理由をコメントで説明する。
サーバー: app/api/upload/route.tsでFormDataを受け取り、MIMEタイプ、拡張子、サイズ上限5MB、空ファイルを検証する。
保存: 元ファイル名は保存名に使わず、UUID + 拡張子で .local-uploads に保存する。
禁止: publicディレクトリへ直接保存しない。拡張子だけで安全と言い切らない。S3 bucketをpublicにしない。
確認: 5MB超過、拡張子不一致、ファイル未選択、正常系のテスト観点を説明する。
参照: MDN File API / FormData / Fetch API。
次に、実運用へ移す版です。
既存のローカル保存アップロードをS3保存に変更してください。
要件: S3 keyは uploads/yyyy/mm/dd/{uuid}.ext。bucketはprivate。APIは公開URLを返さず、保存IDを返す。
検証: MIMEタイプ、拡張子、サイズ、ログインユーザー、利用プランごとの上限をサーバー側で確認する。
セキュリティ: IAMはuploads prefixへのPutObjectだけから始める。DeleteObjectやbucket public policyは追加しない。
UX: 画像はアップロード前プレビュー、PDFはファイル名とサイズ表示、失敗時は再試行できるUIにする。
成果物: storage抽象化、S3実装、アップロードAPI、React UI、テスト、READMEの環境変数。
レビュー観点: コスト、公開範囲、署名付きURL、監査ログ、ウイルススキャンをTODOとして残す。
このくらい具体的に書くと、Claude Codeは「コードを書く人」から「レビュー可能な実装を組み立てる人」に近づきます。
Masaの検証メモ
この記事の構成を作るとき、最初にやったのは「fetchだけで進捗バーまで作る」という依頼をあえて試すことでした。結果として、見た目の進捗は作れるものの、実際のアップロード進捗と結びついていない実装が出やすいと感じました。そこで、進捗が必要な場合はXHRを使う、進捗が不要ならfetchで簡潔にする、という分岐をプロンプトに入れるようにしました。
もう一つ効果があったのは、保存先を最初からS3にしないことです。ローカル保存版でバリデーションとUIを固めてからS3へ移すと、失敗箇所を切り分けやすくなります。逆に、最初から署名付きURL、IAM、CORS、プレビュー、進捗、DB保存を全部まとめると、初心者にはどこで壊れているのか分かりにくくなります。
まとめ: ファイルアップロードは入口より境界設計が大事
Claude Codeでファイルアップロードを作るなら、まずFile API、FormData、fetchの役割を分けて考えます。ブラウザ側では早めにエラーを見せ、サーバー側ではMIME、拡張子、サイズ、保存名、保存場所を必ず検証します。進捗表示が本当に必要ならXHRを使い、S3やCloud Storageへ移すときは公開範囲と権限を最初に決めます。
この流れで作ると、単に「ファイルが送れる」だけではなく、SaaSとして運用できるアップロード機能に近づきます。Claude Codeに任せる範囲と、人間がレビューする範囲を分けるのがコツです。
実際のリポジトリに合わせてアップロード機能、S3保存、署名付きURL、権限レビューまでまとめて整えたい場合は、Claude Code研修・相談で扱えます。無料PDFや教材から試したい方も、まずはそこからどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。