はじめに
マルチテナント SaaS をお仕事でも扱うことがあるのですが、現時点で AWS から提供されているマルチテナント SaaS 関連の技術を詰めて、ボイラープレートを作ってみたいと思います。
今回は、バックエンドAPIを対象に、テナントごとにサブドメイン(app.example.com のような形)を切って配信し、Amazon CloudFront のマルチテナントディストリビューション(SaaS Manager)でエッジからテナントを識別、さらに AWS Lambda で書いた Authorizer で「Host 由来の tenantId」と「JWT 由来の tenantId」を突き合わせてテナント分離を行う構成を作ってみました。
バックエンド API(Amazon API Gateway + AWS Lambda)と Amazon Cognito 認証まで含めた 1 本のボイラープレートです。
実装一式は下記リポジトリに置いてあります。この記事はその要点を解説していきます。
この記事で学べること
- CloudFront マルチテナントディストリビューション
- テナント別サブドメインから tenantId を解決し、エッジからバックエンドまで運ぶ流れ
- Host 由来と JWT 由来の tenantId を Lambda Authorizer で突き合わせるテナント分離設計
- AWS Lambda のテナント分離モード(tenant isolation mode)でテナント単位に実行環境を分離する構成
前提知識・条件
- Amazon CloudFront / Amazon API Gateway / Amazon Cognito / AWS Lambda / AWS CDK を触ったことがある人向けです
- 検証は 2026 年 9 月時点、アジアパシフィック(東京)リージョン(
ap-northeast-1)で行いました
何を作るか
全体像はこんな感じです。
[Client] https://{tenant}.example.com
| HTTPS(ワイルドカード証明書 *.example.com)
v
[CloudFront マルチテナントディストリビューション]
├─ 親: multi-tenant distribution(共有ブループリント。単体では配信しない)
├─ 子: distribution tenant(テナント別。ドメイン = {tenant}.example.com)
├─ connection group(ルーティングエンドポイント。テナント CNAME の向き先)
└─ CloudFront Function: Host から tenant を解決し X-Tenant-Id を付与
| origin にシークレットヘッダー X-Origin-Verify を付与
v
[API Gateway (REST API)]
| REQUEST 型 Lambda Authorizer で認可、ANY /{proxy+} プロキシ統合
v
[Lambda (Node.js + Hono / Lambda-lith)]
クライアントはテナント別サブドメインにアクセスし、CloudFront から API Gateway、Lambda へと流れていきます。
リクエストを一段ずつ追うと、サブドメインでアクセス → CloudFront Function が Host から tenant を解決して X-Tenant-Id を付与 → オリジンへの転送時に X-Origin-Verify を付与 → API Gateway → Lambda Authorizer が検証 → Lambda 本体、という流れになります。
スコープは Backend API + Cognito 認証 + テナント別サブドメインまでです。
tenantId は 2 箇所で指定
この構成では tenantId の指定が 2 箇所あります。
1 つ目は Host 由来の X-Tenant-Id です。CloudFront Function がアクセス元のサブドメインから解決して付けます。
たとえば app.example.com にアクセスすれば X-Tenant-Id: app が付与されます。
これは認証前・JWT 非検証の値なので、経路上で細工される余地があります(詐称できる、という前提で扱う)。
2 つ目は JWT 由来の custom:tenantId です。
Cognito が pre-token-generation trigger でトークンに載せ、Lambda Authorizer が署名検証したうえで取り出します。署名を通っているので、これは信頼できる正の値です。
なお custom:tenantId は ID トークンには標準で載るため、今回の構成だけなら pre-token-generation trigger は必須ではありません。
ロールや権限、複数テナント所属の追加、アクセストークンへの注入(自動では載らない)といった将来の拡張点を用意しておくためです。
今回はこの 2 箇所の tenantId を突き合わせるようにしました。
Host だけを信じると詐称できてしまいますし、JWT だけを見るとアクセス経路(どのサブドメインから来たか)を縛れません。
「サブドメインが示すテナント」と「トークンが示すテナント」の両方が一致して初めて認可する、とすることで、経路とアイデンティティの両面からテナントを固定できます。そして下流に渡すのは常に JWT 由来の値だけにします。
ここからは個別に採用した技術の説明をしていきます。
Cognito: tenantId をトークンへ
まずは正となる JWT 由来の tenantId から見ていきます。
クライアントは API を叩く前に Cognito でログインし、tenantId を載せた ID トークンを受け取ります。User Pool にカスタム属性 custom:tenantId を持たせ、pre-token-generation trigger で ID トークンのクレームに注入する形です。
また、この属性を 不変(mutable: false)にしていることが大切です。
もし mutable: true だと、ユーザーが UpdateUserAttributes で自分の tenantId を書き換えて別テナントへ移動でき、そのテナント用の JWT を取得できてしまいます。
テナント越境を許さないために、属性はサインアップ・管理者作成時に確定する不変値にしています。
this.userPool = new cognito.UserPool(this, 'UserPool', {
selfSignUpEnabled: false,
signInAliases: { email: true },
customAttributes: {
// テナント所属は作成時に確定する不変属性
[bareAttribute]: new cognito.StringAttribute({ mutable: false }),
},
lambdaTriggers: {
preTokenGeneration: preTokenFn,
},
});
trigger 側の処理は、ユーザー属性の custom:tenantId をそのままクレームに載せるだけです。
export const handler: PreTokenGenerationTriggerHandler = async (event) => {
const tenantId = event.request.userAttributes?.[TENANT_ATTRIBUTE];
if (tenantId) {
event.response = {
claimsOverrideDetails: {
claimsToAddOrOverride: {
[TENANT_ATTRIBUTE]: tenantId,
},
},
};
}
return event;
};
全体は packages/authorizer/src/pre-token.ts を参照してください。
CloudFront マルチテナントディストリビューション
CloudFront のマルチテナントディストリビューション(SaaS Manager)は 3 つの要素で構成されます。
共有設定のブループリントになる親(multi-tenant distribution)、それを継承するテナント別の distribution tenant、そしてルーティングエンドポイントを提供する connection group です。
今回の構成に当てはめると、それぞれの役割はこうなります。
- 親(multi-tenant distribution)
- オリジン(API Gateway)やキャッシュ設定、
X-Origin-Verifyの付与といった、全テナント共通の設定をまとめた雛形です。この親自体はドメインを持たず、単体では配信しません。
- オリジン(API Gateway)やキャッシュ設定、
- distribution tenant
- テナントごとに 1 つ作り、それぞれにドメインを紐付けます。今回なら
appテナント用にapp.example.comを持つ distribution tenant を作る、という具合です。設定は親から継承するので、テナント側で個別に持つのはドメインくらいです。
- テナントごとに 1 つ作り、それぞれにドメインを紐付けます。今回なら
- connection group
- 実際に viewer リクエストを受けるルーティングエンドポイント(
xxxx.cloudfront.netのような CloudFront ドメイン)を提供します。テナントのサブドメイン(app.example.com)の CNAME は、この connection group のエンドポイントに向けます。
- 実際に viewer リクエストを受けるルーティングエンドポイント(
親ディストリビューション
親は connectionMode: 'tenant-only' で作ります。オリジン定義とカスタムヘッダー付与のあたりを抜粋します。
const distribution = new cloudfront.CfnDistribution(this, 'MultiTenant', {
distributionConfig: {
enabled: true,
connectionMode: 'tenant-only',
origins: [
{
id: originId,
domainName: originDomain,
originPath,
customOriginConfig: {
originProtocolPolicy: 'https-only',
originSslProtocols: ['TLSv1.2'],
},
originCustomHeaders: [
{
headerName: 'X-Origin-Verify',
headerValue: props.originVerifySecret.secretValue.unsafeUnwrap(),
},
],
},
],
// ... defaultCacheBehavior / viewerCertificate は省略
},
});
ここでオリジンに X-Origin-Verify のシークレットヘッダーを付けていますが、これは後述するオリジン保護に利用します。
全体は packages/cdk/lib/constructs/edge.ts を参照してください。
テナント別サブドメインと CNAME
テナント別サブドメインは、connection group の routing endpoint に CNAME で向けます。
Route 53 のエイリアスではなく CNAME にしているのは、向き先が CloudFront ディストリビューションのドメインではなく connection group のルーティングエンドポイントだからです。
distribution tenant と CNAME をテナントごとに作る処理はこうなっています。
for (const tenant of props.initialTenants) {
const domain = `${tenant}.${props.baseDomain}`;
const distributionTenant = new cloudfront.CfnDistributionTenant(this, `Tenant-${tenant}`, {
distributionId: props.distributionId,
connectionGroupId: this.connectionGroup.attrId,
name: `tenant-${tenant}`,
domains: [domain],
enabled: true,
});
distributionTenant.addDependency(this.connectionGroup);
new route53.CnameRecord(this, `Cname-${tenant}`, {
zone: hostedZone,
recordName: tenant,
domainName: routingEndpoint,
});
}
こちらの全体は packages/cdk/lib/constructs/tenants.ts にあります。
CloudFront Function で tenantId を解決
Host から tenantId を取り出すのは CloudFront Function(viewer-request)で実施しました。
{tenant}.example.com の形からサブドメイン部分を抜き出し、X-Tenant-Id ヘッダーにしてオリジンへ渡します。
baseDomain はデプロイ時に CDK 側でコードに焼き込んでいます。
CloudFront Function は実行時に環境変数を持てないので、設定の単一ソース(config.baseDomain)を置換注入する形です。
function handler(event) {
var request = event.request;
var host = request.headers.host ? request.headers.host.value : '';
var baseDomain = '__BASE_DOMAIN__';
var suffix = '.' + baseDomain;
if (host.length > suffix.length && host.slice(-suffix.length) === suffix) {
var tenant = host.slice(0, host.length - suffix.length);
// 単一ラベルのみ許可(さらにネストしたサブドメインは弾く)
if (tenant.length > 0 && tenant.indexOf('.') === -1) {
request.headers['x-tenant-id'] = { value: tenant };
}
}
return request;
}
全体は packages/cdk/lib/functions/tenant-resolver.js を参照してください。
ここで付く X-Tenant-Id はあくまで Host 由来の値です。信頼できる正ではなく、あとで JWT 由来の値と突き合わせるためのヘッダです。
オリジン保護: CloudFront 経由のみ許可
CloudFront を挟んでも、API Gateway のエンドポイントを直接叩かれてはエッジの検証を素通りされてしまいます。
そこで CloudFront がオリジンへ付与するシークレットヘッダー X-Origin-Verify(AWS Secrets Manager で管理)を Lambda Authorizer で検証し、「CloudFront 経由のリクエストだけ通す」ようにしています。
やり方としては API Gateway のリソースポリシーで弾く方法もありそうですが、今回は採用していません。
リソースポリシーの条件キーには任意の HTTP ヘッダーを参照するものがなく、X-Origin-Verify を条件評価できないためです。
今回は認可を担う Lambda Authorizer に検証を集約しました。
Authorizer は受け取ったリクエストの X-Origin-Verify ヘッダーを Secrets Manager のシークレットと突き合わせ、一致しなければ(= CloudFront を経由していない直アクセスとみなして)その時点で拒否します。
API Gateway のエンドポイントを直接叩いてもこのヘッダーは付かないため、エッジの検証を素通りできません。
Lambda Authorizer
続いて、Lambda Authorizer です。
今回はここに 4 つの責務を集約しています。処理は上から順に、次の流れで進みます。
- オリジン検証 —
X-Origin-Verifyを Secrets Manager のシークレットと照合し、CloudFront 経由でなければ拒否 - JWT 検証 — Bearer トークンと
X-Tenant-Idを取り出し、JWT の署名を検証 - テナント一致検証 — JWT 由来の tenantId と Host 由来(
X-Tenant-Id)を突き合わせ、一致しなければ拒否 - tenantId の供給 — 一致すれば Allow し、下流には JWT 由来の値だけを渡す
ソースは以下の通りです。
Lambda 本体(Hono / Lambda-lith)
バックエンドの Lambda は Hono で書いた Lambda-lith(1 関数に全ルートを集約する構成)です。
hono/aws-lambda の handle(app) でハンドラを作り、API Gateway 側は ANY /{proxy+} のプロキシ統合で全リクエストをこの関数に流します。ルーティングは Hono で実施します。
テナントの解決は、Lambda Authorizer が返した認可コンテキストの tenantId(JWT 由来)を正とします。ミドルウェアで requestContext.authorizer から取り出し、Hono の context 変数に載せる形です。
export const tenantContext = (): MiddlewareHandler<TenantEnv> => {
return async (c, next) => {
const requestContext = c.env?.event?.requestContext as
| { authorizer?: Record<string, unknown> | null }
| undefined;
const tenantId = requestContext?.authorizer?.tenantId;
if (typeof tenantId !== 'string' || tenantId.length === 0) {
return c.json({ message: 'Tenant context missing' }, 403);
}
c.set('tenantId', tenantId);
await next();
};
};
全体は packages/api/src/middleware/tenant.ts にあります。
なお、AWS Lambda には 2025 年 11 月にテナント分離モード(tenant isolation mode)が追加され、X-Amz-Tenant-Id ヘッダーでテナント単位に実行環境(Firecracker)を分離できるようになりました。
このボイラープレートでは、この分離モードを有効にしています。
Lambda を分離モードで作成し、API Gateway 統合で Authorizer が返す JWT 由来の tenantId を X-Amz-Tenant-Id ヘッダーにマッピングして渡します。
分離モードの関数はこのヘッダーが無いと invocation が失敗するため、テナント一致検証を通った正の tenantId をそのまま実行環境の分離キーに使う形です。
動かしてみる
では、デプロイして動作確認してみます。
テストユーザーを作る
custom:tenantId=app を付けてユーザーを作り、恒久パスワードを設定します。
POOL=<UserPoolId> # 例: ap-northeast-1_xxxxxxxxx
USER=test-app@example.com
PASS='Test-Passw0rd!2026'
aws cognito-idp admin-create-user --user-pool-id "$POOL" --username "$USER" \
--user-attributes Name=email,Value="$USER" Name=email_verified,Value=true Name=custom:tenantId,Value=app \
--message-action SUPPRESS --profile <your-profile>
aws cognito-idp admin-set-user-password --user-pool-id "$POOL" --username "$USER" \
--password "$PASS" --permanent --profile <your-profile>
ID トークンを払い出す
App Client は既定で SRP のみ有効なので、CLI から直接パスワード認証するには ADMIN_USER_PASSWORD_AUTH を一時的に有効化します。これは検証専用で、本番運用では有効化しません。
CLIENT=<UserPoolClientId>
aws cognito-idp update-user-pool-client --user-pool-id "$POOL" --client-id "$CLIENT" \
--explicit-auth-flows ALLOW_ADMIN_USER_PASSWORD_AUTH ALLOW_USER_SRP_AUTH ALLOW_REFRESH_TOKEN_AUTH \
--profile <your-profile>
# tokenUse=id を検証しているので、ID トークンを使う
ID_TOKEN=$(aws cognito-idp admin-initiate-auth --user-pool-id "$POOL" --client-id "$CLIENT" \
--auth-flow ADMIN_USER_PASSWORD_AUTH \
--auth-parameters USERNAME="$USER",PASSWORD="$PASS" \
--profile <your-profile> --query 'AuthenticationResult.IdToken' --output text)
# custom:tenantId が入っているか確認
echo "$ID_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | python3 -m json.tool
正しい経路で叩く
{tenant}.example.com(ここでは app.example.com)に Authorization: Bearer <ID トークン> を付けてアクセスします。
X-Tenant-Id はクライアントが付けても CloudFront Function が Host から上書きするので、送らなくて大丈夫です。
BASE=https://app.example.com
# 正常系: 200
curl -s -w '\n%{http_code}\n' -H "Authorization: Bearer $ID_TOKEN" "$BASE/health" # {"status":"ok"}
curl -s -w '\n%{http_code}\n' -H "Authorization: Bearer $ID_TOKEN" "$BASE/me" # tenantId は app
# 認証なし: 401(identity source の 3 ヘッダが揃わないため API Gateway が Authorizer を呼ばず 401)
curl -s -o /dev/null -w '%{http_code}\n' "$BASE/health"
認可の動きを確認する
ここでは、想定した攻撃が実際に防がれるかを 3 つのケースで確かめます。
- API Gateway を直接叩く(オリジン直アクセス)
- 偽の
X-Origin-Verifyを付けて直叩きする - CloudFront 経由で別テナントの
X-Tenant-Idを送り込む(テナント詐称)
の 3 つです。
API=https://<RestApiId>.execute-api.ap-northeast-1.amazonaws.com/v1
# 直叩き(ヘッダなし): 401(identity source が揃わず Authorizer は呼ばれない)
curl -s -o /dev/null -w '%{http_code}\n' "$API/health"
# 直叩き + 偽の X-Origin-Verify(3 ヘッダ揃えて Authorizer を起動させても弾かれる): 403
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $ID_TOKEN" -H "X-Tenant-Id: app" -H "X-Origin-Verify: wrong" \
"$API/health"
# テナント詐称(CloudFront 経由で X-Tenant-Id: other を送る)
# → CloudFront Function が app に上書きするので tenantId は app のまま
curl -s -w '\n%{http_code}\n' -H "Authorization: Bearer $ID_TOKEN" -H "X-Tenant-Id: other" "$BASE/me"
期待どおりに動くと、認証なし = 401、直叩き = 401、偽シークレット = 403、正常系 = 200、そして詐称は app に矯正、となります。
401 と 403 が分かれるのは identity source の扱いによります。
この Authorizer は Authorization / X-Tenant-Id / X-Origin-Verify の 3 つを identity source に指定しているため、どれか 1 つでも欠けると API Gateway は Authorizer を呼ばずに 401 を返します。
一方、3 ヘッダが揃うと Authorizer が起動し、シークレット不一致やテナント不一致は handler の deny()(Deny ポリシー)で弾かれて 403 になります。
まとめ
まとめるとこんなものを作りました。
- テナント分離は、Host 由来(経路)と JWT 由来(署名検証済み)の tenantId を Lambda Authorizer で突き合わせ、下流には常に JWT 由来の値だけを渡すこと
- CloudFront マルチテナントディストリビューションで、ワイルドカードサブドメインからテナントを見分けつつ、
X-Origin-Verifyで「CloudFront 経由のみ許可」を実現できる - Cognito のカスタム属性を不変(
mutable: false)にしておくことで、ユーザー自身によるテナント越境を防げる - 論理分離に加えて、AWS Lambda のテナント分離モードで tenantId ごとに実行環境(Firecracker)を分離し、実行環境レベルでもテナントを隔離できる
実装一式は下記リポジトリにあります。clone してデプロイまで一通り試せるので、手元で動かしてみてください。