はじめに
個人で「AI模試ノート」というAWS認定試験の学習アプリを運用しています。
2026年8月にLambda Web Adapter + Function URL + CloudFrontの構成へ移したのですが、OACを付けるとPOSTだけが403になりました。
原因は、Function URLがボディのSHA256をx-amz-content-sha256ヘッダーで求めるのに、ブラウザのfetch()がこのヘッダーを付けないことでした。
ここでは、Function URLをauthType: NONEに戻し、シークレットヘッダー(x-origin-verify)をNext.js側で確かめる方法によりエラーを回避しています。
そんな中、2026年10月12日にLambdaの新しい関数タイプ「Lambda Web Functions」が発表されました。Lambda Web Functionsをオリジンにすると、CloudFrontはPOSTをUNSIGNED-PAYLOADで署名するので、このヘッダーが不要となります。
shuntaka@製造BT部 さんの記事では、Honoで2MBのPOSTまで通ることが確認されています。
そこで本記事では、Lambda Web Adapterで動かしていたAI模試ノート(Next.js)をLambda Web Functionsに載せ替え、OACのままPOSTが通るかを確かめます。
本記事の情報は2026年10月時点のものです。
最新情報については公式ドキュメントをご確認ください。
この記事でわかること
- Lambda Web Adapter + Function URLのNext.jsを、Lambda Web Functionsに載せ替えるときに変える箇所
- CloudFront OACでLambda Web FunctionをオリジンにするCDK(L1)の書き方
-
x-amz-content-sha256なしのPOSTが通るか、エンドポイントへの直アクセスが拒否されるか - デプロイでつまずいたエラー2つと、その直し方
先に結論
| 項目 | Function URL + OAC | Function URL + シークレットヘッダー(今のLWA構成) | Lambda Web Functions + OAC |
|---|---|---|---|
| GET | ⭕ | ⭕ | ⭕ |
POST(ブラウザのfetch()) |
❌ 403 | ⭕ | ⭕ |
| オリジンへの直アクセス | ❌ 403(IAM認証) | ❌ 403(アプリ側の判定) | ❌ 403(IAM認証) |
| オリジンを守っているもの | IAM認証(SigV4) | ヘッダーの値 | IAM認証(SigV4) |
左2列は前回の記事で確認した結果です。
Lambda Web Functionsに載せ替えたら、OACを付けたままx-amz-content-sha256なしのPOSTがアプリまで届きました。
Function URL + OACの仕様は変わっておらず、Function URLのほうはいまもボディのハッシュが必要です。前回の記事の403は、載せ替えたことで解消しました。
1. Lambda Web AdapterとLambda Web Functions
Lambda Web Adapterとは
Lambda Web Adapter(以下LWA)は、ExpressやNext.jsのような普通のWebアプリを、コードを変えずにLambdaで動かすためのアダプターです。Lambdaが受け取ったイベントをHTTPリクエストに変換し、関数の中で起動しているWebサーバーへ転送します。入れ方は、zipの関数にレイヤーとして足すか、コンテナイメージに組み込むかの2通りです(参考:awslabs/aws-lambda-web-adapter)。
アプリの待ち受けポートはAWS_LWA_PORT(またはPORT、既定8080)で合わせ、ハンドラーにはサーバーを起動するスクリプト(AI模試ノートではrun.sh)を指定します。LWAは変換するだけなので、ブラウザからのリクエストを受けるもの(Function URL、API Gateway、ALBなど)は別に要ります。
AI模試ノートではLambdaAdapterLayerX86のバージョン28 + nodejs22.xで動かし、Function URLで受けていました。
Lambda Web Functionsとは
Lambda Web Functionsは、アダプターを挟まずにNode.jsのHTTPサーバーをそのまま動かせるLambdaの関数タイプです。東京リージョンでも最初から使えます(参考:Announcing AWS Lambda Web Functions)。公式ドキュメントには次のように書かれています(引用)。
You don't need the AWS Lambda Web Adapter or another event-to-HTTP adapter.
つまり、Lambda Web Functionsで動かすならLambda Web Adapterは要らない、ということです。
載せ替えに関わる違いだけまとめると以下の通りです(参考:Lambda quotas)。
| 項目 | LWA(AI模試ノートの今の構成) | Lambda Web Functions |
|---|---|---|
| ランタイム | nodejs22.x |
nodejs24.xのみ |
| アーキテクチャ | x86_64 | arm64のみ |
| メモリ | 1,024MB | 2GB固定 |
| パッケージ | CDKアセット(レイヤー) | S3のバージョニング有効なバケットに置いたzip(API・CDK) |
| 待ち受け | PORT=8080 |
AWS_LAMBDA_HTTP_ENDPOINTで渡される |
| 入口 | Function URLを別に作る | エンドポイントが付属 |
Lambda Web Functionsでは、コードと設定をひとまとめにした版を「リビジョン」と呼びます。通常のLambdaでいうバージョンに近いもので、どのリビジョンにリクエストを流すかはエンドポイントの設定で決めます(参考:Deployment patterns)。
料金面では、Lambda Web Functionsのメモリはリクエストがなくても、実行環境が存在している間は課金されます。東京の単価は1GB秒あたり0.0000042688USD(2026年10月17日時点)なので、2GBの環境が30日まるごと残ると、メモリだけで約22USD(1ドル150円で約3,300円)です。アクセスの少ない個人アプリでは、ここが載せ替えるかどうかの判断材料になります。
ただ、試したところ、アクセスが止まってから約5分で実行環境が消えました。最後のリクエストが12時19分で、12時24分18秒にログへplatform.spinDownStartが出ています。アイドルが続けば環境は残らないので、メモリの課金もそこで止まります。
LWA + Function URLで残っていた認証の問題
LWAで動かす場合、ブラウザからのリクエストはFunction URLで受けるのが手軽です。ところがFunction URLをCloudFront OACで守ると、POSTとPUTに認証の問題が残ります。
CloudFrontのドキュメントでは、利用者側がボディのSHA256を計算してx-amz-content-sha256ヘッダーに入れることになっていて、Lambdaはunsigned payloadに対応していません。
これまでの解決方法は、主に次の3つでした。
| 方法 | やること | 気になる点 |
|---|---|---|
| クライアントでハッシュを付ける(公式の方法) |
fetch()の前にボディのSHA256を計算し、x-amz-content-sha256に入れる |
書き込みのリクエストすべてに手を入れる。クロスオリジンならプリフライトの対応も要る |
| Lambda@Edgeで付ける | origin requestのトリガーでボディを読み、ハッシュを計算してヘッダーに足す | Lambda@Edgeが読めるボディは1MBまでで、それを超えると切り詰められる(Restrictions on Lambda@Edge)。関数が1つ増える |
authType: NONE + シークレットヘッダー |
OACをやめ、CloudFrontが付ける固定のヘッダーをアプリ側で確かめる | Function URL自体は公開のまま。守っているのはヘッダーの値1つ |
AI模試ノートは、前回の記事で3つ目を選びました。Function URLを使う限りは、このどれかを選ぶことになります。
OACでPOSTが通るはずの根拠
一方、Lambda Web Functionsをオリジンにした場合はCloudFrontのドキュメントに次のように書かれています(引用)。
For
PUT,POST, andPATCHrequests, CloudFront signs the origin request with an unsigned payload (UNSIGNED-PAYLOAD). (中略) You do not need to include anx-amz-content-sha256header in requests to CloudFront.
つまり、PUT・POST・PATCHではCloudFrontがボディを署名に含めないので、ボディのハッシュを誰も計算しなくてよくなり、x-amz-content-sha256ヘッダーが不要となります!
ブラウザが送るものは同じで、変わるのはCloudFrontがオリジンへ付ける署名の仕方だけです。
2. 構成
Function URL(authType: NONE + x-origin-verify)の代わりに、Lambda Web Functionのエンドポイント(IamAuth)をOAC付きでオリジンにします。
今はFunction URLを公開したまま、CloudFrontが付けるx-origin-verifyヘッダーをNext.jsのproxy.tsで確かめています(参考:上図の赤字の箇所)。
スタックは次の2つに分けました。
| スタック | 中身 |
|---|---|
| 認証スタック | Cognito User Pool / Identity Pool / adminsグループ、DynamoDBのテーブル |
| アプリスタック | コード置き場のS3、Lambda Web Function / リビジョン / エンドポイント、OAC、CloudFront、リソースポリシー |
2つのスタックは互いを参照しません。CognitoとテーブルのIDは、ビルド時にamplify_outputs.jsonとしてNext.jsのビルドに含めました。
もう1つの違いは静的アセットです。LWA構成では_next/static/*をS3から配っていますが、Lambda Web Functions構成ではS3オリジンを持たず、_next/staticもpublicもLambda Web Functionから返しています。CloudFrontは_next/static/*のビヘイビアだけキャッシュします。CloudFrontはデフォルトドメインで立て、そのままログインまでできました。Next.jsはLWA構成と同じ16.2.10(output: "standalone")です。
CloudFrontのオリジンはLambda Web Functionのエンドポイント1つだけで、S3はリビジョンのコード置き場として使うだけです。
3. Next.jsをLambda Web Functionsで動かす
変更したのは、server.jsの起動方法、zipの中身、zipの置き場所の3点です。
エントリポイントをindex.jsにする
Next.jsのstandaloneはHOSTNAMEとPORTで待ち受けるので、AWS_LAMBDA_HTTP_ENDPOINTを詰め替えるindex.jsをzipのルートに置きます(公式ドキュメントでもNext.jsを名指しで案内されています)。LWAのrun.sh、レイヤー、AWS_LAMBDA_EXEC_WRAPPERなどの環境変数は不要になりました。
// Lambda Web Functions のエントリ(ランタイムは zip 直下の index.js を読み込む)。
// 待ち受けるアドレスは AWS_LAMBDA_HTTP_ENDPOINT(host:port)で渡される。
// standalone の server.js は HOSTNAME / PORT で待ち受け先を決めるので、ここで詰め替える。
"use strict";
// IPv6 のコロンが入っても崩れないよう、最後のコロンで分割する
const endpoint = process.env.AWS_LAMBDA_HTTP_ENDPOINT || "0.0.0.0:3000";
const sep = endpoint.lastIndexOf(":");
process.env.HOSTNAME = endpoint.slice(0, sep);
process.env.PORT = endpoint.slice(sep + 1);
require("./server.js");
Next.jsのstandaloneのserver.jsがCommonJSなので、index.jsもCommonJSで書きました。CloudWatch Logsには- Network: http://0.0.0.0:3000と✓ Ready in 0msが出ました。初期化(initStartからinitReportまで)は約0.5秒で、初期化タイムアウトの10秒には余裕があります。LWA利用時のrun.shで作っていた/tmp/cacheは、作らなくても動きました。
静的アセットとsharpもzipに入れる
S3オリジンをなくしたので、.next/staticとpublicもzipに入れます。組み立てはstandaloneの中身にこの2つとindex.jsを足して固めるだけです。
cp -R "$ROOT_DIR/.next/standalone/." "$PKG_DIR/"
mkdir -p "$PKG_DIR/.next/static" "$PKG_DIR/public"
cp -R "$ROOT_DIR/.next/static/." "$PKG_DIR/.next/static/"
cp -R "$ROOT_DIR/public/." "$PKG_DIR/public/"
cp "$WEBFN_DIR/assets/index.js" "$PKG_DIR/index.js"
# (sharp の差し替えは後述)
(cd "$PKG_DIR" && zip -qr -X -y "$ZIP_PATH" .)
Lambda Web Functionsはarm64固定です。macOSでビルドすると、next/imageが使うsharpのバイナリがmacOS用のまま入るので、linux-arm64用に入れ替えました。
# macOS 用のバイナリを消し、sharp が指定している版の linux-arm64 用を npm pack で取ってきて展開する
SHARP_PKG="$PKG_DIR/node_modules/sharp/package.json"
TMP_PACK="$(mktemp -d)"
rm -rf "$PKG_DIR"/node_modules/@img/sharp-darwin-* "$PKG_DIR"/node_modules/@img/sharp-libvips-darwin-*
for name in sharp-linux-arm64 sharp-libvips-linux-arm64; do
ver="$(node -e 'const p=require(process.argv[1]);console.log(p.optionalDependencies["@img/"+process.argv[2]])' "$SHARP_PKG" "$name")"
(cd "$TMP_PACK" && npm pack --silent "@img/$name@$ver" >/dev/null)
mkdir -p "$PKG_DIR/node_modules/@img/$name"
tar -xzf "$TMP_PACK"/img-"$name"-"$ver".tgz -C "$PKG_DIR/node_modules/@img/$name" --strip-components=1
done
できたzipは24MB、展開後は97MBで、512MBの上限には十分収まりました。
zipをS3に置く
LWAのときより手間が増えたのはここです。コードは基本zipにまとめてS3にアップロードする形でしか渡せず、バケットはバージョニング有効 + Lambdaへの読み取り許可が必要です(参考:公式ドキュメント)。CDKのアセット用バケットもバージョニングは有効ですが、バケットポリシーはブートストラップのスタックが持っていて、アプリのスタックからは足せません。ブートストラップのテンプレートを書き換える手もあるものの、環境で共通のバケットに手を入れることになるので、専用のバケットを別に作りました。
const FUNCTION_NAME = "ai-mogi-webfn-verify";
const codeBucket = new Bucket(this, "CodeBucket", {
versioned: true, // 必須
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
// この Web Function のリビジョンからだけ zip を読めるようにする
codeBucket.addToResourcePolicy(
new PolicyStatement({
principals: [new ServicePrincipal("lambda.amazonaws.com")],
actions: ["s3:GetObject", "s3:GetObjectVersion"],
resources: [codeBucket.arnForObjects("*")],
conditions: {
StringEquals: { "aws:SourceAccount": this.account },
ArnLike: {
"aws:SourceArn": `arn:${this.partition}:lambda:${this.region}:${this.account}:web-function/${FUNCTION_NAME}/*`,
},
},
}),
);
// zip を展開せずに置く。古い zip を消すとリビジョンが Failed になるので prune しない
// destinationKeyPrefix は付けない(5章)
const codeDeployment = new BucketDeployment(this, "CodeDeployment", {
sources: [Source.asset(WEBFN_ZIP_PATH)],
destinationBucket: codeBucket,
extract: false,
prune: false,
});
const codeKey = Fn.select(0, codeDeployment.objectKeys);
const codeVersionId = Fn.select(0, codeDeployment.objectVersionIds);
キーはアセットのハッシュなので、コードが変わればキーも変わり、新しいリビジョンが作られます。リビジョンにはversionIdも渡して、どの版のzipを使うかを固定しました。
参照中のzipが消えるとリビジョンはFailedになり、元に戻せません。
4. CDKでLambda Web FunctionとCloudFrontを作る
LWAからの主な変更点は次のとおりです。Lambda Web Functions向けのL2はまだないので、すべてL1で書きます。今回はaws-cdk-lib 2.273.0を使い、4つのL1はそろっていました。
| 項目 | Before(LWA + Function URL) | After(Lambda Web Functions) |
|---|---|---|
| 関数 |
Function(L2) + LWAレイヤー |
CfnWebFunction / CfnWebFunctionRevision
|
| CloudFrontからの呼び先 | Function URL(authType: NONE) |
CfnWebFunctionEndpoint(IamAuth) |
| OAC | なし(シークレットヘッダー) |
CfnOriginAccessControl + エスケープハッチ |
| 呼び出し許可 | なし | CfnResourcePolicy |
4-1. Lambda Web Function本体とエンドポイント
エンドポイントの認証タイプはIamAuthにします。ApplicationManagedだとCloudFrontの後ろでもドメインに直接届いてしまい、今のNONEと変わりません(参考:Controlling access to Web Function endpoints)。
const webFunction = new CfnWebFunction(this, "WebFunction", { functionName: FUNCTION_NAME });
const revision = new CfnWebFunctionRevision(this, "WebFunctionRevision", {
functionName: FUNCTION_NAME, // ARN ではなく素の名前
buildConfig: {
codeConfig: {
s3Object: { bucket: codeBucket.bucketName, key: codeKey, versionId: codeVersionId },
},
runtimeConfig: { runtime: "nodejs24.x" }, // arm64 固定なので architecture の指定はない
},
serviceConfig: {
executionRoleArn: executionRole.roleArn,
timeoutSeconds: 30,
telemetryConfig: { loggingConfig: { logGroup: logGroup.logGroupName } },
},
});
// バケットポリシーと zip が先に入っていないと Revision の作成に失敗する
revision.node.addDependency(webFunction);
revision.node.addDependency(codeDeployment);
revision.node.addDependency(codeBucket.policy!);
const endpoint = new CfnWebFunctionEndpoint(this, "WebFunctionEndpoint", {
functionName: FUNCTION_NAME,
endpointName: "verify",
endpointType: "HomeRegion",
authType: "IamAuth",
revisionWeights: [{ revisionId: revision.attrRevisionId, weight: 100 }],
scalingConfig: { maxEnvironments: 2 }, // 同時に動く環境を2つまでに絞る
throttleConfig: { rateLimit: 100 }, // 刻みが決まっている(5章)
});
endpoint.node.addDependency(webFunction);
memorySizeやlayersは書く場所ごとなくなり、実行ロールは自分で作ります。アプリはログインユーザーのIdentity Poolの一時認証情報でDynamoDBを読み書きするので、実行ロールはAWSLambdaBasicExecutionRoleだけで足りました。
また、LWA構成で使っていたSSMの読み取り権限も不要になりました。
デプロイされたLambda Web Functionsをコンソールで確認すると、認証タイプ等の設定が反映されていることを確認できます。
4-2. OACを作ってエンドポイントをオリジンにする
Lambda Web Functionのオリジンでは、署名の設定にalways-amz-authしか使えません。L2のSigningBehaviorにはこの値がないため、CfnOriginAccessControlで作ってエスケープハッチで差し込みました。
const oac = new CfnOriginAccessControl(this, "WebFunctionOac", {
originAccessControlConfig: {
name: `webfn-verify-oac-${this.node.addr.slice(0, 8)}`,
originAccessControlOriginType: "lambda",
signingBehavior: "always-amz-auth",
signingProtocol: "sigv4", // HomeRegion なので sigv4 (グローバルなら sigv4a)
},
});
// customHeaders (x-origin-verify) はもう付けない
const origin = new HttpOrigin(endpoint.attrDomainName, {
protocolPolicy: OriginProtocolPolicy.HTTPS_ONLY,
});
const distribution = new Distribution(this, "Distribution", {
defaultBehavior: {
origin,
allowedMethods: AllowedMethods.ALLOW_ALL,
cachePolicy: CachePolicy.CACHING_DISABLED,
// Host を転送すると 404 になる
originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
},
additionalBehaviors: {
// 静的ファイルも同じ Web Function から返し、エッジでキャッシュだけする
"_next/static/*": { origin, cachePolicy: CachePolicy.CACHING_OPTIMIZED },
},
});
// オリジンは2つのビヘイビアで共通の1つだけなので Origins.0 に付ける
(distribution.node.defaultChild as CfnDistribution).addPropertyOverride(
"DistributionConfig.Origins.0.OriginAccessControlId",
oac.attrId,
);
オリジンリクエストポリシーは今と同じALL_VIEWER_EXCEPT_HOST_HEADERです。Hostを転送するとエンドポイントが404を返します。
上図はCloudFrontのOACの詳細画面です。オリジンタイプは「Lambda function URL」と表示されますが、CloudFrontではFunction URLとLambda Web Functionsで同じオリジンタイプ(lambda)を使います。
4-3. CloudFrontだけが呼べるようにする
lambda:InvokeFunctionUrlの代わりにlambda:InvokeWebFunctionEndpointを、関数のARNに付けたポリシーで許可します。
const distributionArn = `arn:${this.partition}:cloudfront::${this.account}:distribution/${distribution.distributionId}`;
new CfnResourcePolicy(this, "WebFunctionResourcePolicy", {
resourceArn: webFunction.attrFunctionArn, // エンドポイントではなく関数の ARN に付ける
policyDocument: {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { Service: "cloudfront.amazonaws.com" },
Action: "lambda:InvokeWebFunctionEndpoint",
Resource: endpoint.attrEndpointArn,
// この Distribution からだけ許可する
Condition: { StringEquals: { "aws:SourceArn": distributionArn } },
},
],
},
});
ポリシーより先にDistributionができるので、デプロイの途中はCloudFront経由でも403になります。
4-4. シークレットヘッダーまわりを消す
SSMのシークレット参照、環境変数ORIGIN_VERIFY_SECRET、customHeadersは不要になりました。ORIGIN_VERIFY_SECRETを渡さなければproxy.tsの判定は素通りしますが、使わなくなるのでブロックごと消します。
export async function proxy(request: NextRequest) {
- const originVerifySecret = process.env.ORIGIN_VERIFY_SECRET;
- if (
- originVerifySecret &&
- request.headers.get("x-origin-verify") !== originVerifySecret
- ) {
- return new NextResponse("Forbidden", { status: 403 });
- }
-
if (!GATED_PREFIXES.some((p) => request.nextUrl.pathname.startsWith(p))) {
前の方式では、シークレットをSSMに平文で置き、ローテーションもしていませんでした。このブロックごと消せるので、管理するものが1つ減ります。
5. つまずいたところ
ここまでの手順でLWAからLambda Web Functionsへの移行を実施しましたが、デプロイは2回失敗しました。
5-1. リビジョンがS3のzipを見つけられない
最初はBucketDeploymentにdestinationKeyPrefix: "code/"を付けていて、CreateWebFunctionRevisionが次のエラーで失敗しました。
The specified S3 object does not exist ... (versionId=...)
objectKeysが返すのはプレフィックスを含まないキー(<ハッシュ>.zip)です。zipはcode/<ハッシュ>.zipに置かれているのに、リビジョンは<ハッシュ>.zipを見に行っていました。destinationKeyPrefixを外してバケット直下に置いたら通りました(プレフィックスを付けたいならFn.joinでcode/を前に足します)。
5-2. rateLimitに50を指定できない
次はCreateWebFunctionEndpointが失敗しました。
The rateLimit 50 is invalid
throttleConfig.rateLimitを50に絞ったつもりでしたが、受け付ける値は0、100〜1000(100刻み)、1000〜10000(1000刻み)のどれかだけでした。100にしたら通りました。
アプリスタックのデプロイは237秒、認証スタックは58秒でした。
6. 動作確認
Distributionのステータスが「デプロイ済み」になってから始めます。公式ドキュメントには、新しい設定が届いたエッジロケーションから順にオリジンへのリクエストへ署名し始める、とあります(Restrict access to an AWS Lambda Web Function origin)。今回はデプロイが終わってから試したので、反映途中の挙動は見ていません。
# CloudFrontのドメイン
$ CF=https://dxxxxxxxxxxxxx.cloudfront.net
# Web Functionのエンドポイントのドメイン(CfnWebFunctionEndpointのDomainName)
$ EP=https://<エンドポイントのドメイン>
GET
LWAのAWS_LWA_ENABLE_COMPRESSIONを外したので、Next.jsのcompressだけで圧縮されるかも見ます。
# -I (HEAD) だと Next.js は圧縮しないので、GET でヘッダーだけを表示する
$ curl -s -D - -o /dev/null -H 'accept-encoding: br, gzip' "$CF/"
HTTP/2 200
content-type: text/html; charset=utf-8
content-encoding: gzip
x-powered-by: Next.js
lambda-web-logstream: 2026/10/19/ai-mogi-webfn-verify[rev-wrvc8]...
lambda-web-request-id: ...
x-cache: Miss from cloudfront
(抜粋)
ステータスは200で、content-encoding: gzipが付いていました。Next.js側の圧縮だけで足りています。
lambda-web-logstreamにリビジョンのID(rev-wrvc8)が出ているので、どのリビジョンが応答したかもヘッダーで分かります。
x-amz-content-sha256なしのPOST
ここが今回いちばん気になっていたところです。
未ログインのcurlなので保存はされませんが、Lambdaの手前で403になるか、アプリの応答が返るかを見ます。
$ curl -s -i -X POST "$CF/api/attempts" \
-H 'content-type: application/json' \
-d '{"dummy":true}'
HTTP/2 401
content-type: application/json
lambda-web-request-id: ...
x-cache: Error from cloudfront
(抜粋)
{"error":"unauthorized"}
返ってきたのは401と{"error":"unauthorized"}でした。未ログインをアプリが弾いた応答で、OACの403ではありません。2MBのボディを送っても401で、アプリまで届いていました。前回の記事では、このリクエストが403で止まっていました。
あわせて、ブラウザでテスト用のユーザーにログインし、5問のセクション演習を解いて保存しました。保存はそのまま通り、ダッシュボードにも結果が反映されています。
以下の抜粋はブラウザから保存したときのリクエストを、開発者ツールで確認したリクエスト・レスポンス情報です。
# 全般
リクエスト メソッド: POST
ステータス コード: 200 OK
# リクエスト ヘッダー
:method: POST
:path: /api/attempts
content-type: application/json
content-length: 298
(cookie などは省略。x-amz-content-sha256 はなし)
# レスポンス ヘッダー
lambda-web-logstream: 2026/10/19/ai-mogi-webfn-verify[rev-wrvc8]...
x-cache: Miss from cloudfront
ブラウザはx-amz-content-sha256を付けずに298バイトのボディを送り、200が返っています。
Hostヘッダーを転送しない設定にしたので、ログイン画面へのリダイレクトがオリジンのドメインに飛ばないかも見ておきました。Locationは相対パスで返ってくるので、CloudFrontのドメインのまま遷移します。
$ curl -sI "$CF/quiz/saa-c03"
HTTP/2 307
location: /account?redirect=%2Fquiz%2Fsaa-c03
エンドポイントへの直アクセス
IamAuthなので、署名のないリクエストはLambdaの手前で403になるはずです。
$ curl -s -i "$EP/"
$ curl -s -i -X POST "$EP/api/attempts" \
-H 'content-type: application/json' \
-d '{"dummy":true}'
どちらも403で、本文はHTTP Error 403、ヘッダーにはlambda-web-request-idが付いていました。CloudFrontを通らない限り、アプリには届きません。
関数まで届いていないことは、CloudWatch Logsの1分ごとの集計(platform.invokeSummary)で確かめました。エンドポイントにだけ5回POSTした1分間は、requestsが0のままでした。
まとめ
LWAで動かしていたAI模試ノートをLambda Web Functionsに載せ替えて、CloudFront OACのままPOSTが通るかを確かめました。
結果、x-amz-content-sha256なしのPOSTはアプリまで届き、ブラウザからの保存も通りました。エンドポイントへの直アクセスは403で弾かれます。
アプリ側で変えたのは、index.jsを足したくらいです。
手間が増えたのはインフラ側で、特にzipをS3に置くまわりでした。
少しハマったのは、BucketDeploymentにdestinationKeyPrefixを付けたときです。objectKeysにはプレフィックスが含まれないため、リビジョンが存在しないキーを見に行って作成に失敗しました。
前回の記事では「Function URLを使うなら最初からauthType: NONE + シークレットヘッダー方式を検討したほうがよい」と書きましたが、Lambda Web Functionsをオリジンにした方が設計がスッキリするため、個人的にはおすすめです。
同じようにOACのPOSTで403に悩んでいた方は、ぜひLambda Web Functionsを試してみてください!
参考
- awslabs/aws-lambda-web-adapter(GitHub)
- Lambda Web Functionsのランタイムと起動方法(AWS Lambda開発者ガイド)
- Lambda Web FunctionsのコードをS3に置く(AWS Lambda開発者ガイド)
- Controlling access to Web Function endpoints(AWS Lambda開発者ガイド)
- Lambda Web FunctionオリジンへのアクセスをOACで制限する(Amazon CloudFront開発者ガイド)
- Lambda関数URLオリジンへのアクセスを制限する(Amazon CloudFront開発者ガイド)
- Restrictions on Lambda@Edge(Amazon CloudFront開発者ガイド)





