バックエンド

Vercel Blob OIDC認証入門——長期トークンなしでNext.jsからファイルを扱う

Vercel BlobのOIDC認証対応を題材に、Next.js App Router、Firestore、GitHub Actionsで長期トークンを減らす設計を実践形式で解説します。

2026年6月22日
Vercel BlobOIDCNext.jsFirestoreGitHub Actions
Vercel Blob OIDC認証入門——長期トークンなしでNext.jsからファイルを扱う

はじめに

契約書、請求書、添付画像のようなファイルを扱う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へ保存します。requireUserassertCanEditContract は、アプリ側で用意する認証・認可ヘルパーです。

// 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 の逆戻りを防げば、運用ルールとしても定着させやすくなります。

参考にした一次情報: