Vercel Blob OIDC認証入門——長期トークンなしでNext.jsからファイルを扱う
Vercel BlobのOIDC認証対応を題材に、Next.js App Router、Firestore、GitHub Actionsで長期トークンを減らす設計を実践形式で解説します。

はじめに
契約書、請求書、添付画像のようなファイルを扱うWebアプリでは、Blob storageそのものより「読み書きトークンをどこに置くか」が運用上の弱点になりがちです。
| よくある課題 | 起きやすい問題 |
|---|---|
BLOB_READ_WRITE_TOKEN を環境変数に置き続ける | Preview、Production、ローカルの境界が曖昧になる |
| CIや補助スクリプトにも同じ値を渡す | 漏えい時の影響範囲がBlob store全体へ広がる |
| Blob URLだけをDBに保存する | 誰が、どの案件に、いつ添付したか追跡しづらい |
2026年6月1日、Vercelは Vercel BlobのOIDC認証対応 を発表しました。新しくプロジェクトに接続するBlob storeではOIDCがデフォルトになり、Vercel上で動くFunctionsは短命で自動ローテーションされるトークンを使ってBlobへアクセスできます。既存storeは、最新の @vercel/blob に更新したうえで、Blob storeのProjectsタブからOIDCへアップグレードできます。
本記事では、架空の契約管理SaaS「ContractPort」を題材に、Next.js App Router + Firestore + TypeScriptで、契約書PDFをVercel Blobへ保存し、Firestoreに監査メタデータを残す流れを作ります。
OIDCで変わる設計
OIDCは、アプリから長期のBlob読み書きトークンを減らすための仕組みです。Vercel上のFunctionが自分のプロジェクトで実行されていることを短命トークンで示し、Blob SDKがその認証情報を使います。
注意点は、OIDCがアプリ側の認可を肩代わりするわけではないことです。誰がどの契約にファイルを添付できるかは、Route Handler内で必ず判定します。
事前準備
依存関係は次の通りです。
yarn add @vercel/blob firebase-admin
ローカルでVercelプロジェクトの環境を使う場合は、公式発表で紹介されているCLIの流れに合わせます。
vercel link
vercel env pull
vercel blob list
ContractPortでは、ファイル本体をBlob、検索・表示・監査に必要な情報をFirestoreへ分けて保存します。
export type ContractFile = {
id: string;
contractId: string;
uploadedBy: string;
blobUrl: string;
pathname: string;
filename: string;
status: "ready" | "deleted";
createdAt: FirebaseFirestore.Timestamp;
};
ハンズオン1: PDFをアップロードする
まず、サーバー経由でPDFをBlobへ保存します。requireUser と assertCanEditContract は、アプリ側で用意する認証・認可ヘルパーです。
// src/app/api/contracts/[contractId]/files/route.ts
import { put } from "@vercel/blob";
import { NextResponse } from "next/server";
import { FieldValue } from "firebase-admin/firestore";
import { adminDb } from "@/lib/firebase/admin";
import { requireUser } from "@/lib/auth/requireUser";
import { assertCanEditContract } from "@/lib/contracts/permissions";
type Params = {
params: Promise<{ contractId: string }>;
};
export async function POST(request: Request, { params }: Params) {
const user = await requireUser(request);
const { contractId } = await params;
await assertCanEditContract(user.uid, contractId);
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File) || file.type !== "application/pdf") {
return NextResponse.json({ error: "PDF only" }, { status: 400 });
}
const fileRef = adminDb.collection("contractFiles").doc();
const pathname = `contracts/${contractId}/${fileRef.id}-${file.name}`;
const blob = await put(pathname, file, {
access: "private",
addRandomSuffix: false,
});
await fileRef.set({
contractId,
uploadedBy: user.uid,
blobUrl: blob.url,
pathname: blob.pathname,
filename: file.name,
status: "ready",
createdAt: FieldValue.serverTimestamp(),
});
return NextResponse.json({ fileId: fileRef.id, url: blob.url });
}
コード側で BLOB_READ_WRITE_TOKEN を読まないため、実装レビューでは「Blob操作はVercel Function上のOIDCに寄せる」という境界が明確になります。
ハンズオン2: Firestoreを台帳にする
Blobはファイル本体の保存先です。一方で、業務画面では「この契約に紐づく有効な添付ファイル」を素早く出す必要があります。そこで、Firestoreの contractFiles を台帳として使います。
import { getFirestore } from "firebase-admin/firestore";
export async function listContractFiles(contractId: string) {
const snapshot = await getFirestore()
.collection("contractFiles")
.where("contractId", "==", contractId)
.where("status", "==", "ready")
.orderBy("createdAt", "desc")
.limit(30)
.get();
return snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}));
}
Firestoreを挟むと、契約ID、担当者、ステータスで検索できます。Blobの一覧を画面表示のたびに直接引くより、業務アプリの権限設計に合わせやすくなります。
ハンズオン3: 削除を安全に扱う
削除では、先にFirestoreで対象ファイルと権限を確認し、Blobの del() を実行してから台帳を更新します。
import { del } from "@vercel/blob";
import { FieldValue } from "firebase-admin/firestore";
import { adminDb } from "@/lib/firebase/admin";
export async function deleteContractFile(fileId: string, userId: string) {
const fileRef = adminDb.collection("contractFiles").doc(fileId);
const snapshot = await fileRef.get();
if (!snapshot.exists) {
throw new Error("File not found");
}
const file = snapshot.data() as {
contractId: string;
pathname: string;
status: string;
};
await assertCanEditContract(userId, file.contractId);
if (file.status !== "deleted") {
await del(file.pathname);
await fileRef.update({
status: "deleted",
deletedBy: userId,
deletedAt: FieldValue.serverTimestamp(),
});
}
}
del() の反映はCDNキャッシュの都合で最大1分程度かかる場合があります。削除直後の画面ではFirestoreの status を信頼し、Blob URLを再表示しない設計にしておくと、画面と業務状態を合わせやすくなります。
GitHub Actionsで逆戻りを防ぐ
OIDCへ移行しても、古いPRで BLOB_READ_WRITE_TOKEN 前提のコードが戻ると効果が薄れます。GitHub Actionsでは、ビルドと一緒に旧変数名の混入を検知します。
name: ci
on:
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "yarn"
- run: yarn install --frozen-lockfile
- run: yarn build
- name: Block legacy Blob token usage
run: |
if rg "BLOB_READ_WRITE_TOKEN" src --glob '!**/*.mdx'; then
echo "Use Vercel Blob OIDC on Vercel Functions."
exit 1
fi
このチェックはSecretsの棚卸しそのものではありません。GitHub repository secretsやVercel環境変数は別途確認が必要です。それでも、アプリコードに長期トークン前提の実装を戻さないガードとしては十分に効きます。
まとめ
Vercel BlobのOIDC対応により、Next.jsのファイル管理機能から長期トークンを減らしやすくなりました。実装では、Route Handlerで認証・認可を済ませてから @vercel/blob を呼び、Firestoreには所有者、契約ID、状態、監査時刻を保存します。
移行は、契約書や請求書のように「ファイル本体はBlob、業務状態はFirestore」と分けやすい機能から始めるのが現実的です。最後にGitHub Actionsで BLOB_READ_WRITE_TOKEN の逆戻りを防げば、運用ルールとしても定着させやすくなります。
参考にした一次情報: