3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【CloudFront OAC × Lambda Function URL】SSRアプリのPOSTだけ403になった話

3
Last updated at Posted at 2026-08-30

はじめに

個人で運用している「AI模試ノート」(AWS認定試験の学習アプリ)を、ビルド時間に対する課金が膨らんできたため、AWS Amplify HostingからAWS CDK(Lambda Web Adapter + CloudFront)への移行をしたところ、ホスティングの切り替え後にテストユーザーから「クイズの結果保存が失敗する」という報告が入りました。
調べてみると、単純なバグではなく、CloudFront OAC + Lambda Function URLという構成そのものが、POST/PUTのボディを持つ通常のWebアプリのバックエンドには使えない、という構成選定段階の見落としでした。

同じ構成を検討している方の参考になればと思い、原因調査から対処までをまとめます。

本記事の情報は2026年8月時点のものです。最新情報については公式ドキュメントをご確認ください。

この記事でわかること

  • CloudFront OAC(Origin Access Control)経由でLambda Function URLにPOST/PUTすると403になる原因
  • authType: NONE + シークレットヘッダー方式への切り替え方(CDK・Next.js Middleware双方のコード)
  • シークレット値をSSM Parameter Store(String)で管理する際に気をつけたこと

ターゲット

  • Lambda Function URLをCloudFrontのOACで保護する構成を検討している方
  • Next.js(App Router)などSSR/API RoutesアプリをLambda + CloudFrontでホスティングしようとしている方
  • 「GETは通るのにPOSTだけ403になる」という現象に遭遇して困っている方

先に結論

CloudFront OACは、Lambda Function URLへのGETリクエストなら問題なく使えます。しかしPOST/PUTについては、リクエストボディのSHA256ハッシュをx-amz-content-sha256ヘッダーに乗せて送る必要があります。x-amz-content-sha256自体はfetch()で送信を禁止されているヘッダーではないので、原理的に不可能というわけではありません。ただ、通常のアプリのfetch呼び出しはボディのハッシュを計算する処理などしていません。自前で実装するには全ての書き込みリクエストにハッシュ計算ロジックを仕込む必要がありますし、クロスオリジンであればこのヘッダーはCORSのセーフリスト外なのでプリフライトへの対応も要ります。つまり、SSR/API Routesを持つ一般的なWebアプリのバックエンドに、そのままOAC(SigV4署名)方式を載せるのは現実的ではありません。

項目 OAC(AWS_IAM) シークレットヘッダー(NONE + customHeaders)
GETリクエスト ⭕ 通る ⭕ 通る
POST/PUTリクエスト(ブラウザのfetch) ❌ 403になる ⭕ 通る
CloudFront以外からの直接アクセス ❌ 拒否される(IAM署名検証) ❌ 拒否される(シークレット不一致)

※シークレットヘッダー方式の「拒否される」は、AWS側の認証機構ではなく、authType: NONEにした上でアプリ側(Next.js Middleware)がヘッダー不一致を見て返しているレスポンスです。Lambda Function URL自体はAWSの認証機構としては公開されたままなので、この点はOAC(AWS_IAM)とは性質が異なります。

対処は、Lambda Function URLの認証をauthType: NONEに戻すことでした。CloudFrontが付与する固定のシークレットヘッダーで「CloudFront経由かどうか」を判定する、OAC登場以前から使われてきた方式です。リクエストの流れで見ると、以下のようになります。

Before: authType AWS_IAM (OAC)

After: authType NONE + シークレットヘッダー

1. 発生した問題

カットオーバー後、実際にブラウザでログインしてクイズに解答すると、結果画面自体は表示されるものの「履歴の保存に失敗しました」というエラーが出ました。これは受験結果をPOST /api/attemptsでサーバーに送り、DynamoDBへ保存する処理が失敗していることを示すメッセージです。

紛らわしいことに、同じ症状を示すバグ報告がカットオーバーの2日前(検証スタックの段階)にすでに上がっていて、その時点では軽微な不具合として扱われていました。しかし深掘りしていくと、これは採用したアーキテクチャそのものの構造的な制約だったことがわかりました。

2. 調査の経緯

2-1. CloudWatch Logsを確認 → 何も出ていない

まず疑ったのは、DynamoDBへの書き込み権限やアプリケーション側のバグです。Lambda関数のCloudWatch Logsを確認しましたが、直近のリクエストに対してアプリケーションレベルのエラーログが一切出力されていませんでした。出ていたのはコールドスタート時の起動ログ(INIT_STARTNext.js 16.2.10Ready in 0msなど)のみで、console.errorの類は皆無です。

これは「サーバーのコードが例外を投げてクラッシュした」のではなく、リクエストがアプリケーションコードに到達する前の層で拒否されていることを強く示唆していました。

2-2. 実際にAPIを叩いて再現

ブラウザの挙動を正確に再現するため、使い捨てのCognitoユーザーを作成し、本番環境に対して直接HTTPリクエストを送って検証しました。

  1. aws cognito-idp admin-create-user / admin-set-user-passwordで使い捨てユーザーを作成(このアプリはセルフサインアップ禁止・招待制のため管理者権限で作成)
  2. aws-amplifysignIn()でSRP認証によるサインイン(このアプリのCognitoはALLOW_USER_SRP_AUTHのみ許可しておりUSER_PASSWORD_AUTHを許可していないため、生のCLIのinitiate-authでは認証できず、SDK経由が必要でした)
  3. 取得したトークンから、@aws-amplify/adapter-nextjsのサーバーサイドセッションが読むCookie形式を組み立てる
  4. そのCookieを付けてfetch()で本番URLに直接アクセス

結果です。

GET  /                          → 200
GET  /api/attempts?examCode=ANS → 200 OK(読み取りは正常)
POST /api/attempts               → 403
{"message":"The request signature we calculated does not match the signature you
provided. Check your AWS Secret Access Key and signing method. Consult the service
documentation for details."}

GETは通るがPOSTだけ403になる。しかもこのエラーメッセージはNext.jsのものではなく、AWSのサービスが直接返す典型的なSigV4署名エラーの文言です。アプリケーションコードには一切到達しておらず、CloudFrontからLambda Function URLへの呼び出し(OACによるSigV4署名検証)の時点で拒否されていたことになります。

2-3. 公式ドキュメントで原因を特定

AWS公式ドキュメントRestrict access to an AWS Lambda function URL originの「Create a new OAC」節、Importantボックスに、まさにこの制約が明記されていました(原文まま引用)。

If you use PUT or POST methods with your Lambda function URL, your users must compute the SHA256 of the body and include the payload hash value of the request body in the x-amz-content-sha256 header when sending the request to CloudFront. Lambda doesn't support unsigned payloads.

要するに、CloudFrontがOAC経由でLambda Function URLにリクエストを転送する際、POST/PUTのボディに対するSigV4署名(ペイロードハッシュ)は、CloudFront自身が計算するのではなく、元のクライアント(ビューア)がx-amz-content-sha256ヘッダーとして用意しておく必要がある、という仕様です。

同じページの末尾には、この仕様を満たすためのサンプルコードも掲載されています。boto3のSigV4Authを使い、hashlib.sha256(body).hexdigest()でボディのハッシュを計算してからx-amz-content-sha256ヘッダーに乗せ、AWS認証情報で自前署名してPOSTするPythonスクリプトです。これはAWSの認証情報を持つクライアント向けのサンプルで、ログイン済みとはいえAWS認証情報は持たない匿名のブラウザユーザーが同じことをするのは現実的ではありません。

今回のfetch()は普通のAPIコールとして書いていて、ボディのハッシュを計算してx-amz-content-sha256ヘッダーに乗せる処理はどこにも書いていません。したがってGET(ボディなし)は素通りし、ハッシュ抜きのPOST/PUT(ボディあり)だけが必ず403になっていたわけです。

実機検証では「ホームページ表示」「認証ゲートのリダイレクト」「静的アセット配信」の3点しか確認しておらず、いずれもGETリクエストでした。このアーキテクチャの制約はPOST/PUTでしか顕在化しないため、ユーザーが実際に書き込み操作を行うまで発覚しませんでした。

3. authType NONE + シークレットヘッダー方式に切り替える

OACによるSigV4署名検証はSSR/API Routesアプリには使えないと判断し、代わりにauthType: NONE + CloudFrontだけが付与できるシークレットヘッダー方式に切り替えました。これはOACが登場する以前から使われてきた、CloudFront経由でのみLambda Function URLへのアクセスを許可する標準的なパターンです。

3-1. CDK側の変更

// Before: OAC (SigV4)。POSTボディがあると403になる
const appFnUrl = appFn.addFunctionUrl({
  authType: FunctionUrlAuthType.AWS_IAM,
});
// ...
origin: FunctionUrlOrigin.withOriginAccessControl(appFnUrl),
// After: authType NONE + シークレットヘッダー
const appFnUrl = appFn.addFunctionUrl({
  authType: FunctionUrlAuthType.NONE,
});
// ...
origin: new FunctionUrlOrigin(appFnUrl, {
  customHeaders: { "x-origin-verify": originVerifySecret },
}),

customHeadersaws-cdk-libOriginProps(FunctionUrlOriginPropsが継承)で定義されているプロパティで、型はRecord<string, string>です。公式ドキュメントには次のように説明されています。

If the header names and values that you specify are not already present in the viewer request, CloudFront adds them to the origin request. If a header is present, CloudFront overwrites the header value before forwarding the request to the origin.

要するに、ビューア(ブラウザ)が同名のヘッダーを送ってきても、CloudFrontは必ず自分が設定した値で上書きしてからオリジンに転送する、ということです。このヘッダーの値はビューア側から偽装できず、知っているのはCloudFrontとLambda(アプリ)だけになります。

なお、CloudFrontのカスタムヘッダーには使用できない名前の一覧があり、X-Amz-X-Edge-で始まるヘッダーなどが該当します。x-origin-verifyのような独自のヘッダー名であればこの制限には引っかかりません。

3-2. アプリ側の変更(Next.js Middleware)

const originVerifySecret = process.env.ORIGIN_VERIFY_SECRET;
if (
  originVerifySecret &&
  request.headers.get("x-origin-verify") !== originVerifySecret
) {
  return new NextResponse("Forbidden", { status: 403 });
}

ORIGIN_VERIFY_SECRETが未設定の環境(Amplify Hosting・ローカル開発)では素通りするようにしているため、既存の実行環境に影響を与えません。

3-3. シークレットの管理

シークレット値はCDKコード内でrandomBytes()のように毎回生成するのではなく、SSM Parameter Store(Stringタイプ)に固定値で一度だけ保存し、CDKからはStringParameter.valueForStringParameter()で参照する形にしました。

毎回のデプロイで値が変わると、CloudFrontのグローバル配信(伝播に数分かかる)の途中で「新しい値を要求するLambda」と「古い値しか送らないエッジロケーション」が一時的に混在します。これが断続的な403を引き起こす懸念があったため、固定値運用にしています。

SecureString(KMS暗号化)ではなくStringを選んだのは、以下の理由です。SSM Parameter Storeの公式ドキュメントには次のように書かれています。

When you include the WithDecryption parameter in a GetParameter request, Parameter Store sends a Decrypt request to AWS KMS with the encrypted SecureString parameter value and the Parameter Store encryption context.

つまりSecureStringは、GetParameterWithDecryption=trueで呼び出すたびに必ずKMSのDecrypt APIが呼ばれます。KMSの対称鍵操作は月20,000リクエストまでは無料枠内ですが、それを超えると課金対象になります。個人的に過去、リクエストのたびに復号が走る設計でこの無料枠を早々に使い切ってしまった経験があり、以来「値そのものに機密性が求められないならStringで十分」という判断をするようにしています。

このシークレットヘッダーはあくまで「素のLambda URLへの直接アクセス・スキャンを防ぐ」ための多層防御レイヤーであり、実際のデータアクセス制御は変わらずCognitoセッションが担っています。各APIルートはリクエストごとにfetchAuthSession()でログインユーザー自身のCognito一時認証情報を取得し、それを使ってDynamoDBの自分のパーティション(USER#{sub})のみを操作します。万一このヘッダーの値が漏洩しても、ユーザーデータには到達できない設計です。この前提があったからこそ、Stringで十分と判断できました。

なお、Stringタイプはssm:GetParameter権限さえあれば誰でも平文で読み出せます。SecureStringのようにKMSキーポリシー側でも二重にアクセス制御をかけられるわけではないので、「漏れても実害がない値かどうか」の見極めはStringを選ぶ側の責任です。機密情報そのものを保存する用途であれば、素直にSecureStringを使うべきだと思います。ちなみにCDKのvalueForStringParameter()はCloudFormationのdynamic referenceとして解決されるため、値そのものは合成後のテンプレートには埋め込まれません。この点はStringでも変わらないメリットです。

4. 動作確認

修正をデプロイ後、同じ手順(使い捨てCognitoユーザー + 直接HTTPリクエスト)で再検証しました。

GET  /api/attempts?examCode=ANS → 200 { "attempts": [], "stats": [], "profile": null }
POST /api/attempts               → 200
{
  "ok": true,
  "attemptId": "ANS#2026-08-30T07:23:18.411Z",
  "streakDays": 1,
  "result": { "total": 1, "correct": 1, "weightedScore": 1000, "passed": true, ... }
}

DynamoDBへの書き込みが成功していることを確認できました。検証用のCognitoユーザーとDynamoDBの書き込み行は検証後に削除しています。

注意点(ハマりどころ)

  • GETだけの実機確認では、この種の制約は発見できません。書き込み系のエンドポイント(フォーム送信・API POST)を最低1つは実際に叩いて確認する必要があります
  • サーバーログに何も出ないエラーは、アプリコードに到達する前の層(CDN・認可・ネットワーク)を疑うのが近道です。今回もCloudWatch Logsが完全に空だったことが、「アプリの外側で何かが起きている」という重要な手がかりになりました
  • AWSが返すエラーメッセージの文言(we calculated does not match the signature you providedなど)は、そのサービス特有の定型文であることが多いです。見慣れないエラー文言はそのまま検索・公式ドキュメント参照する価値が高いと感じました

まとめ

CloudFront OAC + Lambda Function URLは、POST/PUTのボディがある通常のWebアプリのバックエンドには使えません。SigV4のペイロードハッシュをクライアント自身が用意する前提の機能のため、SSR/API Routesを持つNext.jsアプリなどをこの構成でホスティングする場合は、最初からauthType: NONE + シークレットヘッダー方式を検討したほうがよいというのが今回の教訓です。

ドキュメントを読んだだけでは「OACはLambda Function URLも保護できる、公式推奨のパターン」という理解で止まってしまいがちですが、実際にPOSTを叩いてみてはじめて、この制約の意味が腹落ちしました。次はAWS WAFをこのCloudFrontディストリビューションに組み合わせて、シークレットヘッダー方式と多層防御をさらに固める構成にも挑戦してみたいです。

参考

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?