Boxでは、フォルダやファイルへの操作(アップロード、削除、コメント追加など)をトリガーにWebhookで通知を受け取れます。しかし、Webhook用のエンドポイントは基本的に外部からPOSTを受け付けるため、何も対策をしなければ「Boxを装った偽のリクエスト」を見分けられません。

この記事では、Boxが提供する署名検証の仕組みを使って、受信したWebhookが本当にBoxから送られたものかをPythonで確認する方法を、実際に手を動かしながら解説します。

この記事で作るもの

  • Box Webhookを受信するCloud Run Functions(Python)
  • BOX-SIGNATURE-PRIMARY / BOX-SIGNATURE-SECONDARY ヘッダーを使った署名検証ロジック
  • Secret Managerと専用サービスアカウントによる、署名キーを平文で残さないデプロイ構成
  • Cloud Run Functionsへのデプロイと、実際のBoxアカウントからの動作確認

1. Webhook署名検証の仕組み

Box固有の実装に入る前に、そもそも「Webhookの署名検証」が一般的にどういう仕組みなのかを押さえておきます。

Webhookエンドポイントは外部からのPOSTを無条件に受け付けるため、以下の2点を確認できないと「なりすまし」を防げません。

  • 認証性: そのリクエストは本当にBoxから送られたものか
  • 完全性: リクエストの内容(ボディ)は送信途中で改ざんされていないか

これを確認する方式はいくつか考えられますが、よくあるのは次の2通りです。

  1. 固定の共有シークレットをそのままヘッダーに載せる方式:実装は単純ですが、シークレットの値自体を毎回ネットワークに流すことになり、通信経路やログへの漏洩リスクが相対的に高くなります
  2. HMAC(Hash-based Message Authentication Code)署名方式:共有シークレットと本文をハッシュ関数にかけた結果(署名)だけを送ります。受信側は同じシークレットで同じ計算をやり直し、値が一致するかどうかで検証します。シークレット自体はネットワークに流れません

BoxはこのHMAC署名方式を採用しています。仕組みはシンプルで、送信者(Box)と受信者(こちらのアプリ)が同じ秘密鍵を持っている前提のもと、次のように検証します。

秘密鍵を知らない第三者は、たとえボディの内容やアルゴリズム(HMAC-SHA256であること)を知っていても、正しい署名を偽造できません。これがHMAC署名方式の安全性の根拠です。

Boxの実装では、この一般的なHMAC署名方式に加えて「本文だけでなく配送タイムスタンプも一緒に署名対象に含める」ことでリプレイ攻撃(過去に送られた正規リクエストの再送)にも対応しています。次章以降で、Boxが具体的にどのような手順でこれを検証しているかを見ていきます。

2. 事前準備:BoxのCCGアプリと署名キーを用意する

Webhookを受信・検証するには、BoxでCCG(Client Credentials Grant)タイプのアプリを作成し、そのアプリに紐付いた署名キーを取得する必要があります。

2-1. Free Developer Accountの準備

CCGアプリの設定には、Free Developer Account(または Business 以上)が必要です。個人の無料プランではWebhookの管理ができません。

アカウント種別 CCGアプリ Webhook管理
個人の無料プラン × ×
Free Developer Account ○ ○
Business 以上 ○ ○

Free Developer Accountの取得方法はBoxのFree Developer Accountとはを参照してください。

2-2. CCGアプリを作成する

  1. Box Developer Console にアクセスする
  2. Create New App をクリックする
  3. App Name に任意の名前を入力する
  4. App Type で Server を選択する
  5. Select Method で Client Credentials Grant を選択する(デフォルトで選択済み)
  6. Create をクリックする

アプリ作成後、構成 タブの アプリケーションスコープ で以下のスコープを有効にして 保存 をクリックします。

グループ スコープ名 必要か
コンテンツ操作 Boxに格納されているすべてのファイルとフォルダの読み取り 必須
開発者操作 Webhookを管理する 必須

「Boxに格納されているすべてのファイルとフォルダの読み取り」は、この記事のmain.py自体がBox APIを呼び出すために使うものではありません。Webhookを対象のフォルダに紐付けるには、作成する側(CCGのサービスアカウント)がそのフォルダにアクセスできる状態である必要があり、この記事の手順ではフォルダを個別にコラボレーション(共有)する手順を取らないため、このスコープが唯一のアクセス手段になります。

承認状態は Developer Console の右サイドバーで確認できます。Free Developer Account では自動的に承認済みになります。Enterprise アカウントの場合の承認手順はBox公式ドキュメントを参照してください。

2-3. 署名キーを生成する

  1. Webhook タブを開く
  2. 署名キーを管理 をクリック
  3. プライマリキー の キーを生成 をクリックし、表示された値を控えておく
  4. 同様に セカンダリキー も生成して控えておく

Boxでは、この2つのキーを同時に有効にできる仕組みになっています。片方だけを新しいキーに切り替えても、もう片方が有効なままなので、通知を取りこぼさずにキーをローテーションできます。控えた2つのキーは、後述のSecret Managerに登録して使用します(コマンド履歴やCloud Runのメタデータに平文で残さないため)。

3. 検証ロジックを理解する

自分でHMACを計算しなくても、Boxの公式Python SDKに検証用のメソッドが用意されているので、今回はそれを使います。ただし中で何が行われているかを知らないままSDKを呼ぶのは気持ちが悪いので、仕組みだけ先に押さえておきます。

  1. ヘッダー形式の確認:BOX-SIGNATURE-VERSIONヘッダーが1、BOX-SIGNATURE-ALGORITHMヘッダーがHmacSHA256であることを確認する。この2つは常に固定値としてBoxから送られてくる(公式ドキュメントに一覧がある)
  2. タイムスタンプの検証:BOX-DELIVERY-TIMESTAMPヘッダーの値が現在時刻から10分以内かを確認する。これより古い場合はリプレイ攻撃の可能性があるため拒否する
  3. HMACの計算:リクエストボディに続けてタイムスタンプを結合したものに対して、Primary/Secondary両方のキーでHMAC-SHA256を計算する
  4. Base64エンコード:計算したHMACをBase64文字列に変換する
  5. 署名の比較:Base64化した値を、それぞれBOX-SIGNATURE-PRIMARY/BOX-SIGNATURE-SECONDARYヘッダーの値と比較する。どちらか一方でも一致すれば有効なリクエストとみなす

この5ステップをすべてまとめて実行してくれるのが、次に使うSDKのvalidate_messageメソッドです。

補足: 手順1のBOX-SIGNATURE-VERSION/BOX-SIGNATURE-ALGORITHMのチェックは、公式の署名検証ガイドには明記されていませんが、ヘッダー一覧のドキュメントとSDKの実装(box_sdk_genのcompute_webhook_signature)の両方で必須のチェックとして定義されています。

4. Pythonで実装する(公式SDK利用)

Box公式のPython SDKをインストールします。SDKはv10から配布パッケージ名がboxsdkに統一されましたが、Pythonのimport文では引き続きbox_sdk_genという名前を使う点に注意してください(名前の変遷が少しややこしいですが、現時点の正式な状態です)。

今回はCloud Run Functionsとしてデプロイするため、Functions Framework for Pythonを使います。Functions Frameworkは内部的にFlaskのRequestオブジェクトをそのまま関数に渡してくれる仕組みのため、後述のコードにもflaskのimportが登場しますが、リクエストの扱い方自体はFlaskの標準的な使い方と同じです。

pip install functions-framework "boxsdk>=10"

なお、v10より前のboxsdk(旧世代)にも似たWebhook.validate_message(body, headers, primary_key, secondary_key)という静的メソッドがありますが、こちらはbodyをバイト列で渡す仕様です。検索で古い記事やコード例に当たった場合、引数の型の違いに注意してください。

以下がCloud Run Functionsとしての実装例です。

# main.py
import os

import flask
import functions_framework
from box_sdk_gen import WebhooksManager

PRIMARY_KEY = os.environ.get("BOX_PRIMARY_KEY")
SECONDARY_KEY = os.environ.get("BOX_SECONDARY_KEY")

@functions_framework.http
def box_webhook(request: flask.Request) -> flask.typing.ResponseReturnValue:
# WebhooksManager.validate_message は body を文字列で受け取る仕様
raw_body = request.get_data(as_text=True)

# SDK内部は小文字キーでヘッダーを参照するため、ここで正規化しておく
headers = {key.lower(): value for key, value in request.headers.items()}

try:
is_valid = WebhooksManager.validate_message(
body=raw_body,
headers=headers,
primary_key=PRIMARY_KEY,
secondary_key=SECONDARY_KEY,
)
except Exception:
# ヘッダー欠落・形式不正などSDK内部で例外が出た場合も無効な署名として扱う
is_valid = False

if not is_valid:
print("Invalid or expired webhook signature. Rejecting request.")
return flask.jsonify({"error": "invalid signature"}), 403

payload = request.get_json(silent=True) or {}
print(f"Verified webhook event: {payload.get('trigger')}")

return flask.jsonify({"status": "ok"}), 200
# requirements.txt
functions-framework==3.*
boxsdk>=10

補足: 環境変数のデフォルト値には""ではなくNoneを使っています。WebhooksManager.validate_messageはキーがNoneの場合のみ検証をスキップする仕様のため、デフォルトを""にすると「環境変数の設定漏れ」が「空文字列というキーでの検証成立」にすり替わり、鍵が空であることを知る攻撃者に署名を偽造されてしまいます。またvalidate_messageの呼び出しをtry/exceptで囲んでいるのは、BOX-DELIVERY-TIMESTAMPヘッダーが欠落した不正なリクエストを受けた際にSDK内部で例外が送出され、意図した403ではなく500エラーになってしまうのを防ぐためです。

5. Cloud Run Functionsにデプロイして動作確認する

実装が完了したら、実際にBoxからWebhookを受け取れるように、公開HTTPSエンドポイントとしてCloud Run Functionsにデプロイします。

 

作業に入る前に、この章で使うAPIをまとめて有効化しておきます。--sourceを指定したソースデプロイでは、コンテナのビルドに Cloud Build、イメージの保存に Artifact Registry が使われるため、Cloud Run本体と合わせて4つが必要です。

gcloud services enable \
run.googleapis.com \
cloudbuild.googleapis.com \
artifactregistry.googleapis.com \
secretmanager.googleapis.com \
--project=

5-1. 署名キーをSecret Managerに登録する

署名キーを環境変数にそのまま渡すと、gcloudのコマンド履歴やCloud Runのサービス設定に平文で残ってしまいます。Secret Managerにキーを登録し、Cloud Run Functionsからは参照だけさせる構成にします。

printf '%s' '' | gcloud secrets create box-primary-key \
--project= \
--replication-policy=automatic \
--data-file=-

printf '%s' '' | gcloud secrets create box-secondary-key \
--project= \
--replication-policy=automatic \
--data-file=-

5-2. 専用のサービスアカウントを用意する

Cloud Run Functionsはデフォルトで、プロジェクト内の他のCompute Engine/Cloud Runリソースとも共有されるデフォルトサービスアカウント(-compute@developer.gserviceaccount.com)を使います。これに直接シークレットへのアクセス権を与えると、同じデフォルトSAを使う他のサービスにもキーが読める状態になってしまうため、このFunctions専用のサービスアカウントを作成し、そのSAにだけシークレットへのアクセス権を絞ります。

gcloud iam service-accounts create box-webhook-verify-sa \
--project= \
--display-name="box-webhook-verify Cloud Run Functions"

gcloud secrets add-iam-policy-binding box-primary-key \
--project= \
--member="serviceAccount:box-webhook-verify-sa@.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"

gcloud secrets add-iam-policy-binding box-secondary-key \
--project= \
--member="serviceAccount:box-webhook-verify-sa@.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"

補足: 次の§5-3で--service-accountを指定してデプロイする際、デプロイを実行するユーザー(またはCI)がこの専用SAに対するroles/iam.serviceAccountUser(iam.serviceAccounts.actAs)権限を持っている必要があります。プロジェクトのOwner/Editorであれば通常は暗黙に持っていますが、権限が絞られたユーザーで実行するとPERMISSION_DENIED: Permission 'iam.serviceAccounts.actAs' denied on service account ...のようなエラーで止まることがあります。その場合は次のコマンドで付与してください。

  gcloud iam service-accounts add-iam-policy-binding \
  box-webhook-verify-sa@.iam.gserviceaccount.com \
  --project= \
  --member="user:<デプロイを実行するユーザーのメールアドレス>" \
  --role="roles/iam.serviceAccountUser"
  

5-3. デプロイする

main.pyとrequirements.txtを置いたディレクトリで、以下のようにgcloud run deployを実行します。プロジェクトIDは省略せず、--projectで明示的に指定します。環境変数は--set-env-varsではなく--set-secretsで渡し、実行に使うサービスアカウントも--service-accountで専用のものを明示します。

gcloud run deploy box-webhook-verify \
--project= \
--source=. \
--function=box_webhook \
--base-image=python312 \
--region=asia-northeast1 \
--allow-unauthenticated \
--service-account=box-webhook-verify-sa@.iam.gserviceaccount.com \
--set-secrets="BOX_PRIMARY_KEY=box-primary-key:latest,BOX_SECONDARY_KEY=box-secondary-key:latest"

参考: Quickstart: Deploy a Cloud Run function using the gcloud CLI

デプロイが完了すると、以下のようなService URLが発行されます。

https://box-webhook-verify-xxxxxxxxxx.asia-northeast1.run.app

発行されたService URLを、Boxの管理画面でWebhookの通知先として登録します。

  1. Box Developer Console で対象アプリを開き、Webhook タブを開く
  2. Webhookを作成 をクリックする
  3. URLアドレス に発行されたService URLを入力する
  4. コンテンツタイプ(Webhookをトリガーする項目のタイプ)で、監視対象のファイル/フォルダをピッカーから選択する(例: 監視したいフォルダ)
  5. 該当するトリガー種別のセクション(例: File Trigger)を開き、監視したいイベント(例: File Uploaded)にチェックを入れる
  6. Webhookを作成 をクリックする

5-4. 実際に動かして確認する

監視対象のフォルダにファイルをアップロードしてみましょう。ログを確認します。

gcloud run services logs read box-webhook-verify \
--project= \
--region=asia-northeast1

以下のようなログが出力されれば、実際のBoxアカウントからの署名検証に成功しています。

2026-09-24 13:04:08 POST 200 https://box-webhook-verify-xxxxxxxxxx.asia-northeast1.run.app/
2026-09-24 13:04:12 Verified webhook event: FILE.UPLOADED

invalid signatureが出る場合は、Secret Managerに登録した署名キーとDeveloper Console側のキーが一致しているか確認してください。

5-5. 署名なしで外部からアクセスした場合の挙動を確認する

--allow-unauthenticatedでデプロイしているため、このURLはBox以外の第三者からもアクセス可能な状態です。§1で触れた「Boxを装った偽のPOSTリクエスト」を実際に模してみて、署名検証ロジックが正しく拒否できるか確認します。

curl -i -X POST https://box-webhook-verify-xxxxxxxxxx.asia-northeast1.run.app/ \
-d '{"trigger":"FILE.UPLOADED"}'
HTTP/2 403
...
{"error":"invalid signature"}

ログには以下のように出力され、Boxからの正規リクエストと区別されて拒否されていることがわかります。

2026-09-24 14:42:08 POST 403 https://box-webhook-verify-xxxxxxxxxx.asia-northeast1.run.app/
2026-09-24 14:42:08 Invalid or expired webhook signature. Rejecting request.

エンドポイント自体は公開されていますが、BOX-DELIVERY-TIMESTAMPやBOX-SIGNATURE-PRIMARYなどのヘッダーを持たないリクエストは、WebhooksManager.validate_messageの内部で例外(タイムスタンプ欠落)または不一致(署名欠落)として処理され、403で拒否されます。ネットワークレベルでの認証を課さず署名検証だけで安全性を担保する、というBox Webhook V2本来の設計がそのまま機能していることが確認できます。

6. 運用時の注意:キーのローテーション

セキュリティを高めるために、署名キーは定期的に変更することが推奨されています。手順はBox公式のキーローテーションガイドに沿っています。

Boxでは2つの署名キーを同時に有効にできます。SDKのvalidate_messageはプライマリ・セカンダリ両方のキーで検証を試み、どちらか一方でも一致すれば有効と判定します。この仕組みにより、通知を取りこぼさずにキーを交換できます。

ローテーションの肝は、Box側とアプリ(Cloud Run Functions)側とで、切り替えのタイミングに時間差があることです。この間もどちらかのキーが一致すれば通知が処理され続けます。

ステップ Box側プライマリ Box側セカンダリ アプリ側プライマリ アプリ側セカンダリ 通知処理
通常時 Key A Key B Key A Key B 正常
Boxでプライマリをリセット Key C(新) Key B Key A Key B 正常(Key Bで一致)
シークレット更新+リビジョン作成 Key C Key B Key C Key B 正常(Key Cで一致)
Boxでセカンダリをリセット Key C Key D(新) Key C Key B 正常(Key Cで一致)
シークレット更新+リビジョン作成 Key C Key D Key C Key D 正常

以下の手順でキーを切り替えます。

  1. Developer Consoleの Webhook → 署名キーを管理 でプライマリキーをリセットして新しい値を発行する
  2. box-primary-keyシークレットに新しいバージョンを追加する(この間もセカンダリキーが有効なので通知は正常に処理され続ける)
printf '%s' '<新しいPrimary Keyの値>' | gcloud secrets versions add box-primary-key \
--project= \
--data-file=-

:latestを参照するシークレットも、新しいリビジョンを作らない限り古いバージョンを使い続けます(Cloud Run公式ドキュメントにも既存インスタンスは新しいバージョンを自動では読み込まない旨の記載があります)。gcloud run services updateはフラグなしでは「変更なし」としてエラーになるため、--update-secretsに同じ参照を明示して新しいリビジョンを作成させます。

gcloud run services update box-webhook-verify \
--project= \
--region=asia-northeast1 \
--update-secrets="BOX_PRIMARY_KEY=box-primary-key:latest,BOX_SECONDARY_KEY=box-secondary-key:latest"
  1. 新しいプライマリキーでの検証が問題なく通っていることを確認できたら、同じ手順でセカンダリキー(box-secondary-key)も更新する

一度に両方のキーを切り替えないことがポイントです。

まとめ

Box Webhookの署名検証は、CCGアプリの作成・署名キーの取得・Python SDKのWebhooksManager.validate_messageの呼び出しという流れで実装できます。SDKが検証の複雑な処理を抽象化してくれるため、実装自体はシンプルです。署名キーをSecret Managerで管理し、専用サービスアカウントで最小権限のデプロイ構成にすること、そして定期的なキーローテーションを運用に組み込んでおくと安心です。