Vercel Blobで画像のプライベートアクセスを実装してみた

Vercel Blobで画像のプライベートアクセスを実装してみた

2026.04.26

こんにちは、豊島です。

LPにおけるキャンペーン施策で「アンケート回答者にだけ壁紙を配布する」「投票参加者にだけSNSアイコンをプレゼントする」といったユースケースがあると思います。
画像の直接URLアクセスを防ぎつつ、条件を満たしたユーザーにだけコンテンツを配布する仕組みが必要です。

Vercel Blobにはストア作成時に選ぶpublicとprivateの2つのアクセスモードがあり、配布物の特性によって使い分けが必要です。
今回はSvelteKitアプリケーション上で両モードを実装し、ユースケースごとにどちらを選ぶべきかを整理しました。

検証シナリオ

「アンケート(Survey)を完了したユーザーにだけ画像を表示する」というシナリオを想定し、以下の2方式を実装して比較します。

  • 方式A
    • publicモード + addRandomSuffix(URLの推測を不可能にする)
  • 方式B
    • privateモード + Function経由のストリーミング配信(リクエスト時に毎回認可する)

認可判定の前提

本記事のサンプルでは、説明をシンプルにするためアンケート完了フラグをCookie(survey_completed=true)の値だけで判定しています。
ただしこの方式は、ブラウザ側で document.cookie = 'survey_completed=true' と書き換えるだけでバイパスできてしまいます。
本番運用ではサーバー側で改ざん耐性のある署名付きトークンを発行したり、httpOnly Cookieに絞ったりするなど、改ざんされにくい認可機構と組み合わせるのが安全そうです。

以降の実装コードはあくまでVercel Blobの2モードを比較するためのサンプルである、という前提で読んでいただけると幸いです。

Vercel Blobの2つのアクセスモード

Vercel Blobはストア作成時にpublic/privateのどちらかを選択します(参考: Vercel Blob 公式ドキュメント

観点 publicモード privateモード
書き込み 認証必須 認証必須
読み取り URLを知っていれば誰でも 認証必須(トークン or Function経由)
配信経路 Blob CDNから直接配信 Function経由でストリーミング配信
用途 公開素材・大容量メディア 機密ファイル・ユーザー固有コンテンツ

作成後に変更できない項目に注意

Vercel Blob Storeは作成時の以下の設定を後から変更できません。
publicで作ってしまったストアをprivateに切り替えたい場合は、新しいストアを作ってblobをコピーし直す必要があります。

  • アクセスモード(public/private)
  • リージョン(20リージョンから選択)

公式ドキュメントでも以下のように明記されています。

It's important to choose the correct access mode for your use case since you cannot change it after the creation of a blob store.
You cannot change the region once the store is created.
(引用元: Vercel Blob 公式ドキュメント

特に本記事の「アンケート回答者だけに画像を渡したい」というユースケースでは、後から「やっぱり認可ロジックを毎回走らせたい」となった場合、publicモードのストアでは対応できず、privateモードのストアを新規に作り直すことになります。
配布物の機密性とトラフィック特性を見極めて、ストア作成前にどちらのモードで運用するかを決めておくのがおすすめです。

方式A: publicモード + addRandomSuffix

仕組み

publicモードのストアではBlob URLを知っていれば誰でも直接アクセスできます。
URLが流出すれば配布対象外のユーザーにも渡ってしまうため、以下2点で制御します。

  • アップロード時に addRandomSuffix: true を指定し、ファイル名にランダムな文字列を付与してURLを推測不能にする
  • そのURLをサーバーサイドのload関数内でのみ返却し、条件を満たさないユーザーには返さない
  1. 画像をpublicモードのBlob Storeにアップロード(URLにはランダムサフィックスが付与される)
  2. サーバーサイドのload関数でCookieを確認する
  3. アンケート回答済み(survey_completed=true)ならBlob URLをクライアントに返す
  4. 未回答なら画像URLを返さない

画像のアップロード

キャンペーン素材のように事前に決まったアセットを配布するだけなら、Dashboardの「Storage → Blob → ファイル一覧」からドラッグ&ドロップで直接アップロードもできます。
本記事ではアプリ経由のアップロードフローも検証したかったので、SDKの put() を使うコードを示します。

import { put } from '@vercel/blob';
import { env } from '$env/dynamic/private';

const blob = await put(`private-demo/${file.name}`, file, {
  access: 'public',
  addRandomSuffix: true,
  token: env.BLOB_READ_WRITE_TOKEN
});

ここでのポイントは3つあります。

  • access: 'public'を指定している点(publicストアにアップロード対象を指定)
  • addRandomSuffix: trueでURLを推測不能にしている点
  • tokenを明示的に渡している点(後述するSvelteKit固有のハマりどころ)

サーバーサイドでのURL返却

サーバーサイドのload関数でCookieを確認し、条件を満たすユーザーにだけBlob URLを返します。

import { list } from '@vercel/blob';
import { env } from '$env/dynamic/private';

export const load = async ({ cookies }) => {
  const surveyCompleted = cookies.get('survey_completed') === 'true';
  if (!surveyCompleted) {
    return { imageUrl: null };
  }

  const { blobs } = await list({
    prefix: 'private-demo/',
    token: env.BLOB_READ_WRITE_TOKEN
  });

  if (blobs.length === 0) {
    return { imageUrl: null };
  }

  return { imageUrl: blobs[0].url };
};

Blob URLそのものは公開アクセス可能ですが、ランダムサフィックスによりURLを知らなければアクセスできません。
サーバーサイドでURLの返却を制御することで、URLを知らないユーザーには事実上アクセス不能となります。

方式Aの限界

一度発行されたBlob URLは無期限に有効で、サーバー側から個別に無効化する手段がありません。
無効化したい場合は対象のblobを削除します。削除した瞬間に配布済みURLは404を返すようになります。
ただし、同じpathnameで再アップロードしてもランダムサフィックスが新たに付与されるため、旧URLが復活することはありません(新URLとして発行され直す形になります)
SNS等にURLが流出した場合は止められないため、機密性の高いコンテンツには不向きです。
壁紙やSNSアイコンのような「拡散しても問題ないキャンペーン素材」であれば許容できる範囲かもしれません。

方式B: privateモード + Functionストリーミング配信

仕組み

privateモードのストアにアップロードしたblobは、URL(https://<store-id>.private.blob.vercel-storage.com/<pathname>)を知っていてもそのままではアクセスできません。
読み取りには BLOB_READ_WRITE_TOKEN が必要で、SDKの get() でサーバーサイドから取得し、Functionからブラウザへストリーミング配信するのが標準パターンです(参考: Private Storage 公式ドキュメント

  1. Vercel DashboardまたはCLIでprivate Blob Storeを作成
  2. access: 'private' を指定して画像をアップロード
  3. SvelteKitのサーバーendpointでCookieを確認 → 条件を満たす場合のみ get() でストリーミング配信
  4. ブラウザはendpoint URL(/api/file?pathname=...)を <img src="..."> で参照する

private Blob Storeの作成

CLIから作成する場合は --access private オプションを指定します。

vercel blob create-store wallpaper-store --access private

DashboardからはStorageタブの「Create Database → Blob → Access: Private」を選択して作成します。

画像のアップロード

privateストアの場合もDashboardから直接アップロードできるので、固定アセットの配布だけならわざわざAPIを実装する必要はありません。
SDK経由でアップロードする場合は、access: 'public' の代わりに access: 'private' を指定するだけです。
privateストアではURL自体が秘匿されるため、addRandomSuffix は不要になります。

import { put } from '@vercel/blob';
import { env } from '$env/dynamic/private';

const blob = await put(`wallpaper/${file.name}`, file, {
  access: 'private',
  token: env.BLOB_READ_WRITE_TOKEN
});

サーバーendpointでのストリーミング配信

SvelteKitの +server.tsでendpointを定義し、Cookie認可 → get() → ストリーミング応答という流れで実装します。

// src/routes/api/file/+server.ts
import { get } from '@vercel/blob';
import { env } from '$env/dynamic/private';

export const GET = async ({ url, cookies }) => {
  const surveyCompleted = cookies.get('survey_completed') === 'true';
  if (!surveyCompleted) {
    return new Response('Forbidden', { status: 403 });
  }

  const pathname = url.searchParams.get('pathname');
  if (!pathname) {
    return new Response('Missing pathname', { status: 400 });
  }

  const result = await get(pathname, {
    access: 'private',
    token: env.BLOB_READ_WRITE_TOKEN
  });

  if (!result || result.statusCode !== 200) {
    return new Response('Not found', { status: 404 });
  }

  return new Response(result.stream, {
    headers: {
      'Content-Type': result.blob.contentType,
      'X-Content-Type-Options': 'nosniff',
      'Cache-Control': 'private, no-cache'
    }
  });
};

クライアント側からは通常の <img src="/api/file?pathname=wallpaper/sample.png"> で参照できます。
リクエスト到達時にendpointで認可チェックが走るため、Cookieが無い状態では403が返ります。

方式Bの限界

<img> でURLを参照するたびにFunction実行が発生するため、Function起動コストとデータ転送コストがかかります。
公式ドキュメントでも「100MBを超える大きなファイルや高トラフィック配信には推奨されない」と明記されています。
壁紙程度のサイズであれば問題ありませんが、動画など大容量・高トラフィックの配信は方式Aを選ぶか、別の認可機構を検討する方が現実的です。

検証結果

SvelteKitアプリケーションで両方式とも動作確認しました。挙動は同じため、ここでは方式Aの画面で結果を示します。

Survey未完了の状態でページにアクセスした場合、画像は表示されません。

Survey未完了時の画面

Survey完了後に同じページにアクセスすると、画像が正しく表示されます。

Survey完了後の画面

方式Bでも同様に、Cookie未設定時はendpointが403を返し画像が表示されない、Cookie設定後はストリーミング配信されて表示される、という挙動になります。

方式A vs 方式B 比較

観点 方式A: public + addRandomSuffix 方式B: private + Function配信
セットアップ publicストア作成のみ privateストア + 配信endpoint実装
URL流出時のリスク URLが有効な限り誰でもアクセス可能 認可ロジックを通らないとアクセス不可
配信経路 Vercel Blob CDNから直接配信 Function経由でストリーミング
配信コスト CDN直配信のため安価(公式ベンチで約3倍効率的) Function実行コストとデータ転送コストが上乗せ
大容量ファイル 適している 100MB超は非推奨
アクセス制御の柔軟さ URL返却時の判定のみ リクエストごとに認可判定可能
適するユースケース 壁紙・SNSアイコン等の公開アセット 機密ファイル・ユーザー固有コンテンツ

どちらを選ぶか

アンケート回答者への壁紙配布のような、機密性は低いがURL推測を防ぎたいケースには方式Aが適しています。
CDN直配信のため配信コストが安く、大容量にも耐えられます。

一方、以下の要件がある場合は方式Bを推奨します。

  • URL流出のリスクを最小限にしたい
  • リクエストごとに認可ロジックを走らせたい(ユーザー単位で権限を変える等)
  • 配布対象が小〜中サイズのファイル(100MB以下)

繰り返しになりますが、ストアのアクセスモードは作成後に変更できないため、迷ったときは要件をしっかり整理してから作成したほうがよさそうです。
判断に迷う場合は、リスクを抑えられるprivateモードで作ってFunction経由で配信し、後からトラフィックや配信コストを見てpublicモード+別ストアへの移行を検討する、というアプローチが安全に思えます。

SvelteKitでのハマりどころ

@vercel/blob のトークン自動解決が動かない(vite dev 時)

@vercel/blobのSDK内部では、token オプションが省略された場合に process.env.BLOB_READ_WRITE_TOKEN を直接参照する実装になっています。

// node_modules/@vercel/blob/dist/chunk-XXX.js より引用
function getTokenFromOptionsOrEnv(options) {
  if (options?.token) return options.token;
  if (process.env.BLOB_READ_WRITE_TOKEN) return process.env.BLOB_READ_WRITE_TOKEN;
  throw new BlobError('No token found. ...');
}

ところがSvelteKit (Vite)のローカル開発では、.env.local の値は $env/dynamic/privateimport.meta.env には注入される一方、process.env には注入されません。
そのため token を省略すると上記の getTokenFromOptionsOrEnvNo token found エラーを投げます。

実際に同じエンドポイントから挙動を確認したログがこちらです(@vercel/blob 2.3.1 / SvelteKit 2.50 / Vite 7.3 で検証)

{
  "processEnvHasToken": false,
  "dynamicEnvHasToken": true,
  "autoResolveOk": false,
  "autoResolveError": "Vercel Blob: No token found. ...",
  "explicitTokenOk": true
}

process.env 側は空で、$env/dynamic/private 側にだけ値が入っているのがわかります。
回避策はシンプルで、$env/dynamic/private から取り出した値を token に明示的に渡すだけです。

import { put, get, list } from '@vercel/blob';
import { env } from '$env/dynamic/private';

await put(pathname, file, {
  access: 'private',
  token: env.BLOB_READ_WRITE_TOKEN
});

Next.jsではNext.js自身が .env* の値を process.env に注入するため、token を省略しても自動解決が機能します。
本番デプロイ時(@sveltejs/adapter-vercel 経由でVercel Functionとして動作する場合)はVercelプラットフォーム側で process.env がセットされるため自動解決が動く可能性が高いですが、ローカルとの挙動差を避ける意味でも明示的に渡しておくのが無難です。

環境変数の自動設定はProduction/Previewのみ

Blob Storeをプロジェクトに接続すると BLOB_READ_WRITE_TOKEN が自動設定されますが、対象はProductionとPreview環境のみです。
ローカル開発時は vercel env pull.env に取り込むか、Dashboardから手動で取得しておくとよさそうです。

まとめ

  • Vercel Blobにはpublic/privateの2つのアクセスモードがあり、ストア作成時に選択する(後から変更不可)
  • publicモード + addRandomSuffixはCDN直配信で安価・高速、URL流出時は止められないがキャンペーン素材には十分
  • privateモード + Functionストリーミング配信はリクエストごとに認可判定できるが、大容量・高トラフィックには不向き
  • SvelteKitで @vercel/blob を使う場合は token の明示指定が必要
  • アクセスモードとリージョンは作成後に変更できないため、配布物のサイズと機密性を見極めてから作成する

この記事をシェアする

関連記事