こんにちは、wandです!
以前の記事で紹介した通り、このブログシステムは AWS 上に CloudFront + S3 構成で作られています。
今回は、その開発環境に Lambda@Edge と Cognito を使って認証を実装した方法を詳しくご紹介します。
このアプローチにより、コストを抑えつつ、高いセキュリティと利便性を両立させることができます。
具体的な設定コードは GitHub リポジトリに公開していますので、ぜひご覧ください。
開発環境にアクセス制御が必要な理由
このブログシステムは、本番環境と開発環境の2環境を用意しています。
- 本番環境: 読者が利用する公開環境
- 開発環境: 以下の用途を兼ねる内部環境
- インフラやフロントエンド修正の動作確認・テスト
- 下書き記事のプレビューや公開前の最終チェック
開発環境が誰でもアクセスできる状態だと、以下のような問題が発生する可能性があります。
- 未公開記事の流出
下書き記事が不特定多数に閲覧されるリスクがあります。 - API Key の漏洩
本ブログは Next.js の SSG (静的サイト生成) でページを生成していますが、下書きプレビューは SPA(シングルページアプリケーション)で動作します。この際、クライアントサイドで microCMS の API を呼び出しているため、開発環境が一般公開されると API Key が漏洩する恐れがあります。
特に、 API Key が悪用されると、レートリミットに達し、記事の更新や公開ができなくなるリスクが発生します。このようなリスクを避けるため、開発環境へのアクセス制御は必須となります。
アクセス制御の方法
AWS WAF + IP 制限の課題
一般的な手法として AWS WAF(Web Application Firewall)を用いた IP 制限がありますが、次のような課題があります。
- 柔軟性の欠如
- 出先の Wi-Fi やモバイルネットワークからアクセスできない
- 家庭用インターネット環境では IP アドレスが変動するため、都度設定の更新が必要
- 高コスト
- WAF の月額料金(約 5 USD ~)は、個人開発者にとって負担が大きい
- 境界型セキュリティの限界
- 「安全な接続元 IP アドレス」を全面的に信頼することは、近年のランサムウェア被害を考慮するとリスクが伴う
解決策: Lambda@Edge + Cognito による認証
前述の課題を解決するため、CloudFront 上で Lambda@Edge と Cognito User Pool を組み合わせた認証方式を採用しました。この構成のメリットは以下の通りです。
- 柔軟なアクセス制御
- IP アドレスに依存せず、ユーザー名とパスワードで認証が可能
- 「Google でログイン」などのソーシャルログインにも対応可能
- 低コスト
- Lambda@Edge と Cognito の無料枠を活用し、ほぼ無料で運用できる
- ゼロトラストネットワークへの対応
- 境界型セキュリティを超えた、より進んだセキュリティアプローチを取り入れられる
「Google でログイン」の設定については下記の記事で解説しています!
補足: セキュリティの割り切り
この構成は、静的コンテンツ配信におけるコスト最適化を目的としています。CloudFront + S3 の構成では、攻撃リスクは比較的低いと判断しています。
ただし、将来的に動的なバックエンドを追加し、攻撃リスクが高まる場合には、WAF の導入を検討する予定です。
実装
AWS インフラは AWS SAM (Serverless Application Model) を使って IaC(Infrastructure as Code)で管理しているため、SAM テンプレートを交えて実装方法を説明します。
UseAccessControl
condition の定義
SAM(CloudFormation)では、condition (条件)に基づいてリソースを生成できます。
CognitoUserPool:
Type: AWS::Cognito::UserPool
Condition: UseAccessControl # UseAccessControl が true の場合のみリソースが生成される
アクセス制御関連の AWS リソースは開発環境限定で生成したいため、判定用の condition を定義します。
Parameters:
# 1. 環境を受け取る
Environment:
Type: String
Description: "デプロイ先環境"
Default: stg
AllowedValues:
- stg
- prd
# ...
Conditions:
# 2. 「本番環境であるか」を判定する IsProd 条件
IsProd: !Equals
- !Ref Environment
- 'prd'
# 3. 「本番環境でない場合」にアクセス制御を有効化する UseAccessControl 条件
UseAccessControl: !Not
- !Condition IsProd
- SAM テンプレートの
Parameters
セクションでEnvironment
パラメータを定義し、デプロイ先環境を受け取ります。 Conditions
セクションで、「本番環境であるか」を判定するIsProd
条件を定義します。- 「本番環境でない場合」にアクセス制御を有効化するための
UseAccessControl
条件を定義します。
将来的に stg
以外の開発環境を増やす可能性を考慮し、「本番環境でない」という定義にしました。
Cognito
以下の3リソースを定義します:
- UserPool: ユーザープール本体
- UserPoolClient: OpenID Connect 準拠のクライアント。認証を付けたいアプリケーションに対する設定を記述する
- UserPoolDomain: Cognito のログイン画面のドメイン
テンプレートは以下のようになります:
# ...
CognitoUserPool:
Type: AWS::Cognito::UserPool
Condition: UseAccessControl
Properties:
UserPoolName: !Sub ${AWS::StackName}-userpool
# (A) デフォルト ESSENTIALS なので注意!
UserPoolTier: LITE
Policies:
PasswordPolicy:
MinimumLength: 8
RequireLowercase: true
RequireNumbers: true
RequireSymbols: true
RequireUppercase: true
TemporaryPasswordValidityDays: 7
AdminCreateUserConfig:
AllowAdminCreateUserOnly: true
AccountRecoverySetting:
RecoveryMechanisms:
- Name: verified_email
Priority: 1
UsernameConfiguration:
CaseSensitive: false
VerificationMessageTemplate:
DefaultEmailOption: CONFIRM_WITH_CODE
MfaConfiguration: 'ON'
EnabledMfas:
- SOFTWARE_TOKEN_MFA
Schema:
- Name: email
AttributeDataType: String
Mutable: true
Required: true
EmailConfiguration:
EmailSendingAccount: COGNITO_DEFAULT
CognitoUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Condition: UseAccessControl
Properties:
UserPoolId: !Ref CognitoUserPool
ClientName: !Sub ${AWS::StackName}-userpoolclient
GenerateSecret: false
ExplicitAuthFlows:
- ALLOW_USER_SRP_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
PreventUserExistenceErrors: ENABLED
AllowedOAuthFlows:
- code
AllowedOAuthScopes:
- email
- openid
- phone
- profile
AllowedOAuthFlowsUserPoolClient: true
SupportedIdentityProviders:
- COGNITO
CallbackURLs:
# 本番環境以外はサブドメインがつくので条件分岐
- !If
- IsProd
- !Sub https://${Domain}
- !Sub https://${Environment}.${Domain}
LogoutURLs: []
CognitoUserPoolDomain:
Type: AWS::Cognito::UserPoolDomain
Condition: UseAccessControl
Properties:
UserPoolId: !Ref CognitoUserPool
Domain: !Sub ${AWS::StackName}
# (B) 1 を指定すると従来の「 Hosted UI 」となる
ManagedLoginVersion: 1
Cognito UserPool は2024年11月に大幅アップデートがあり、ログイン画面のカスタマイズ (「マネージドログイン」機能) などが導入され、料金プランが改定されました。
執筆時点では、デフォルトは ESSENTIALS というミドルレンジのプランとなっています。 今回のケースでは、ログイン画面を使用するのは自分だけなので、画面カスタマイズは不要とし、料金プランも最も安価な LITE
を選択しました(A)。LITE
プランでは「マネージドログイン」機能は使用できず、従来の「 Hosted UI 」のみが使用可能です。ManagedLoginVersion
パラメータに 1
を指定すると従来の「 Hosted UI 」を選択できます (B)。
「マネージドログイン」機能を利用時の注意
ESSENTIALS 以上のプランで「マネージドログイン」機能を利用する場合、ログイン画面のカスタマイズが必須となります。
カスタマイズせずにログイン画面にアクセスすると、以下のようなわかりにくいエラーメッセージが表示されるので注意が必要です。
Login pages unavailable
Please contact an administrator.


これは、2024年11月以前の感覚で、マネジメントコンソール上で Cognito を構成した場合に陥りやすいミスです。
SSM Parameter
Lambda@Edge にて認証ロジックを実装するためには、以下の情報が必要となります:
- UserPoolId
- UserPoolClientId
- UserPoolDomain
Lambda@Edge は環境変数をサポートおらず、またハードコードも避けるべきです。そのため、これらの情報を SSM Parameter Store に格納し、Lambda@Edge から読み出す方法を採用しました。
次に問題となるのは、「Lambda@Edge に Parameter 名をどのように渡すか?」です。次の案を検討しました:
- 没: Parameter 名を環境変数で渡す案
これは Lambda@Edge の仕様上不可能です。 - 没: Parameter 名をグローバル変数定義し、関数のビルド時に埋め込む
例えば Vite ならばdefine
の設定で実現が可能です。しかし今回は SAM の esbuild plugin を利用しており、 esbuild 本体にこのようなオプションはありませんでした。除外します。
- 採用: Parameter 名を関数ソースコード中にハードコードする案
今回はこのアプローチをとることとしました。
以下は SAM テンプレートの設定です:
# ...
CognitoUserPoolIdParameter:
Type: AWS::SSM::Parameter
Condition: UseAccessControl
Properties:
Name: !Sub '/${AWS::StackName}-lambdaedge-access-control/user-pool-id'
Tier: Standard
Type: String
Value: !GetAtt CognitoUserPool.UserPoolId
CognitoUserPoolClientIdParameter:
Type: AWS::SSM::Parameter
Condition: UseAccessControl
Properties:
Name: !Sub '/${AWS::StackName}-lambdaedge-access-control/user-pool-client-id'
Tier: Standard
Type: String
Value: !GetAtt CognitoUserPoolClient.ClientId
CognitoUserPoolDomainParameter:
Type: AWS::SSM::Parameter
Condition: UseAccessControl
Properties:
Name: !Sub '/${AWS::StackName}-lambdaedge-access-control/user-pool-domain'
Tier: Standard
Type: String
Value: !Sub ${CognitoUserPoolDomain}.auth.${AWS::Region}.amazoncognito.com
/(関数名)/user-pool-id
のような Path 設計 を採用し、(関数名)
の部分は Lambda の handler の context から取得します。
Lambda@Edge
handler
Cognito による認証を Lambda@Edge で実装するために、npm パッケージ「cognito-at-edge」が awslabs によって公開されています。
このパッケージを利用し、 handler
の実装は以下のようになりました:
import { Authenticator } from "cognito-at-edge";
import type { CloudFrontRequestHandler } from "aws-lambda";
import { fetchParams } from "./fetchParams";
export const handler: CloudFrontRequestHandler = async (request, context) => {
// NOTE: region が . 区切りで結合されているので、除去
const functionName = context.functionName.split(".").pop();
const { userPoolId, userPoolAppId, userPoolDomain } =
await fetchParams(functionName);
const authenticator = new Authenticator({
region: "us-east-1",
userPoolId,
userPoolAppId,
userPoolDomain,
cookiePath: "/",
});
return authenticator.handle(request);
};
関数名を用いて SSM Parameter パラメータを取得し、 cognito-at-edge パッケージの Authenticator
クラスに渡します。
注意点として、Lambda は CloudFront から Lambda@Edge として呼び出される際に、context.functionName
に region が結合される仕様です。
例えば、関数名が blog-stg-lambdaedge-access-control
の場合、context.functionName
は us-east-1.blog-stg-lambdaedge-access-control
となります。
(CloudWatch Logs のロググループ名も /aws/lambda/us-east-1.blog-stg-lambdaedge-access-control
となります)
今回は blog-stg-lambdaedge-access-control
の部分だけが必要なので、 .
で分割して region 部分を捨てています:
// NOTE: region が . 区切りで結合されているので、除去
const functionName = context.functionName.split(".").pop();
SSM Parameter 取得部
SSM Parameter 取得部分は以下のように実装しました:
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
/**
* ハンドラ外領域にキャッシュする
*/
let memo: {
userPoolId: string;
userPoolAppId: string;
userPoolDomain: string;
} | null = null;
/**
* Lambda@Edge では環境変数が取得できないので SSM Parameter Store から取得
*/
export const fetchParams = async (prefix: string) => {
if (memo) return memo;
const ssmClient = new SSMClient({
region: "us-east-1",
});
// NOTE: GetParameters は順序が保証されないので使用しない
const [
{
Parameter: { Value: userPoolId },
},
{
Parameter: { Value: userPoolAppId },
},
{
Parameter: { Value: userPoolDomain },
},
] = await Promise.all([
ssmClient.send(
new GetParameterCommand({
Name: `/${prefix}/user-pool-id`,
}),
),
ssmClient.send(
new GetParameterCommand({
Name: `/${prefix}/user-pool-client-id`,
}),
),
ssmClient.send(
new GetParameterCommand({
Name: `/${prefix}/user-pool-domain`,
}),
),
]);
return memo = { userPoolId, userPoolAppId, userPoolDomain };
};
SSM の GetParameter API を呼び出し、3つのパラメータを並列で取得しています。また、取得した結果を Lambda のハンドラ外領域にキャッシュし、warm start 時に再利用できるようにしています。
なお、SSM には GetParameters という API もあり、これを使えば一度の API 呼び出しで 3 つのパラメータを取得できます。
しかし、AWS 公式ドキュメント では以下のように説明されています:
The results for
GetParameters
requests are listed in alphabetical order in query responses.
つまり、GetParameters API では、パラメータ名ではなく取得した値に基づいてアルファベット順に並べ替えられるため、順序が重要なこのケースでは使用できませんでした。
dynamic require のワークアラウンド
sam deploy
を実行して動作確認を行った際、以下のエラーが出力されました:
{
"errorType": "Error",
"errorMessage": "Dynamic require of \"crypto\" is not supported",
"stack": [
"Error: Dynamic require of \"crypto\" is not supported",
" at file:///var/task/app.mjs:12:9",
" at node_modules/aws-jwt-verify/dist/cjs/jwt-rsa.js (file:///var/task/app.mjs:819:20)",
" at __require2 (file:///var/task/app.mjs:15:50)",
" at node_modules/aws-jwt-verify/dist/cjs/index.js (file:///var/task/app.mjs:1245:24)",
" at __require2 (file:///var/task/app.mjs:15:50)",
" at node_modules/cognito-at-edge/dist/index.js (file:///var/task/app.mjs:18882:28)",
" at __require2 (file:///var/task/app.mjs:15:50)",
" at file:///var/task/app.mjs:19635:38",
" at ModuleJob.run (node:internal/modules/esm/module_job:234:25)",
" at async ModuleLoader.import (node:internal/modules/esm/loader:473:24)"
]
}
内部的に crypto
パッケージを動的に require
している部分があり、そのためエラーが発生しています。
GitHub の issue を参考にし、以下のように esbuild の Banner オプションでワークアラウンドを追加したところ、エラーが解消しました。
#...
AccessControlFunction:
Type: AWS::Serverless::Function
Condition: UseAccessControl
Properties:
# ...
Metadata:
BuildMethod: esbuild
BuildProperties:
Format: esm
Minify: false
OutExtension:
- .js=.mjs
Target: "es2020"
Sourcemap: false
EntryPoints:
- app.ts
External:
- aws-lambda
- '@aws-sdk/*'
# HACK: crypto の dynamic require のエラーを回避する
# see https://github.com/aws/aws-sam-cli/issues/4827#issuecomment-1574080427
Banner:
- js=import { createRequire } from 'module'; const require = createRequire(import.meta.url);
「この方法で SSM Parameter 名も渡せたのでは…?」という気がしなくもないですが、いろいろなアプローチを試せるということで、今回はこれで良しとしました!
CloudFront の Cache Behavior に設定
作成した Lambda 関数を DefaultCacheBehavior
に紐づけ、開発環境限定で動作させます:
# ...
Distribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
DefaultCacheBehavior:
# ...
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized
OriginRequestPolicyId: acba4595-bd28-49b8-b9fe-13317c0390fa # UserAgentRefererHeaders
LambdaFunctionAssociations:
- !If
- UseAccessControl
- LambdaFunctionARN: !Sub ${AccessControlFunction.Version}
EventType: viewer-request
- !Ref AWS::NoValue
- LambdaFunctionARN: !Sub ${IndexSupplierFunction.Version}
EventType: origin-request
# ...
LambdaFunctionAssociations
には配列を指定します。本番環境と開発環境で異なる内容を設定するために、CloudFormation の Fn::If
関数と AWS::NoValue
を活用して実現します。
認証状態がキャッシュされないように、origin-request
ではなく viewer-request
に設定することに注意しましょう。
ユーザ登録~認証動作確認
今回はセルフサインアップを無効化しているため、ユーザプールにあらかじめユーザを登録しておきます。

MFA(多要素認証)などの設定も可能です。

準備が整いましたので、開発環境にアクセスするとログイン画面 (Cognito Hosted にリダイレクトされます。

ユーザ名とパスワードを入力し、「Sign in」を押下します。
初回ログイン時には、パスワード変更や多要素認証の設定が求められます。


私は Google Authenticatorを使用しているため、そこで生成されたコードを入力します。
次回以降のログインでは、多要素認証が求められます。

コードを入力し、再度「Sign in」を押下すると、開発環境に正常にアクセスできました!

CognitoIdentityServiceProvider.*
という Cookie に OpenID Connect の idToken や accessToken などが格納されているのが確認できます。
まとめ
Lambda@Edge と Cognito を組み合わせることで、開発環境へのアクセス制御が簡単に実現できました。
特に、AWS WAF による IP 制限と比較して柔軟性が高く、コスト面でも個人開発者にとって非常に優れた選択肢です。
将来的に動的なバックエンドを追加する場合や、より高度なセキュリティが求められるシナリオにおいては、WAFとの併用も可能です。
境界型セキュリティに加え、Lambda@Edge と Cognito を活用した新しいアプローチを手札に加えることで、より堅牢で柔軟なシステム構築が可能になります。要件に合えばぜひ検討してみてください!
今後も有益な情報を発信していきます。どうぞご期待ください!