はじめに
前回、Amazon CloudFront のマルチテナントディストリビューションと AWS Lambda の Lambda Authorizer で、バックエンド API のテナント分離を実装してみました。
前作は API 層でのテナント分離のみでしたので、フロントエンドのログイン部分には課題が残っていました。
全テナントで 1 つの Amazon Cognito User Pool を共有し、Hosted UI のセッションも共有されるため、tenant-a のユーザーが tenant-b のサイトにログインできてしまいます。
今回はその続編として、フロント(React SPA)と認証まわりを、API と同じサブドメイン識別の方法でテナント分離してみました。
自分はフロントエンドにあまり詳しくない立場なので、SPA まわりは手探りでしたが、設計としてはシンプルに落とし込めたかなと思います。
リポジトリは前作と同じものです。今回はそこに追加した分の解説になります。
この記事で学べること
- API と同じく、サブドメインにテナント識別子を入れて URL を分ける方法(
{tenant}.example.com) - テナント分離を Cognito の App Client 単位で行う構成
前提知識・条件
- 前作(API 編)の構成を前提にします。CloudFront マルチテナントディストリビューション、Amazon API Gateway、Lambda Authorizer あたりは前作を参照してください。
- フロントは React SPA(Vite ビルド、Amplify Auth v6、TanStack Router)です。
- 検証環境: アジアパシフィック(東京)リージョン(
ap-northeast-1)、2026 年 9 月時点。
何を作るか(全体像)
やることは 3 つです。
- サブドメインにテナント識別子を入れて URL を分ける(
{tenant}.example.com) - Cognito のテナント分離を App Client 単位で行う
- 権限がないユーザーがログインすると Forbidden ページへ飛ばす
後述する部分もありますが、図にするとこんな感じです。
[ブラウザ] https://{tenant}.example.com
| ① 起動時に /tenant-config.json を取得(Host から自テナント分だけ返る)
| → userPoolId / userPoolClientId / hostedUiDomain を実行時に解決
v
[Amazon Cognito Hosted UI] ログイン(App Client はテナントごと)
| ② 認証後、ID トークン(custom:tenantId 付き)を取得
v
[フロント認証ガード] トークンの custom:tenantId と現在ホストのテナントを突合
├─ 一致 → ダッシュボード表示(/api/me は前作どおり 200)
└─ 別テナント → /forbidden へ(画面に入れない。/api/me は前作どおり 403)
ポイントは、テナント識別の起点をあくまで「今どのサブドメインを開いているか」に置くところです。API 編と同じ考え方にしました。
サブドメインにテナント識別子を入れて URL を分ける(やったこと①)
API 編と同じく、{tenant}.example.com でテナントを表します。
フロントも同じサブドメインに同居させ、CloudFront が Host からテナントを解決します。
前作では {tenant}.example.com/health のようにサブドメイン直下で API を受けていましたが、同じオリジンに SPA を同居させると、SPA のルーティング(/ や /forbidden など)と API のパスがぶつかります。
そこで API を {tenant}.example.com/api/health のように /api プレフィックスへ寄せ、CloudFront のふるまいで /api/* を API Gateway、それ以外を SPA(S3)へ振り分けるようにしています。
フロントからは相対パスの /api/... を叩くだけで済みます。
フロント側は「今どのテナントのサブドメインを開いているか」を起点にします。この tenantId を起動時に確定させ、後段の App Client 選択と権限チェックに使う、という流れです。
起動時のテナント解決は packages/web/src/lib/amplify.ts にあります。/tenant-config.json を fetch し、返ってきた設定で Amplify を構成します。
export async function configureAmplify(): Promise<void> {
const cfg = await fetchTenantConfig();
currentTenantId = cfg.tenantId;
// OAuth のリダイレクト先は「今アプリを開いているオリジン」を実行時に決める。
const redirectUrl = window.location.origin;
Amplify.configure({
Auth: {
Cognito: {
userPoolId: cfg.userPoolId,
userPoolClientId: cfg.userPoolClientId,
loginWith: {
oauth: {
domain: cfg.hostedUiDomain,
scopes: ['openid', 'email', 'profile'],
redirectSignIn: [redirectUrl],
redirectSignOut: [redirectUrl],
responseType: 'code',
},
},
},
},
});
}
解決した tenantId は getCurrentTenantId() で後段から参照できるようにしています。
テナント分離は App Client 単位(やったこと②)
現状の Cognito は、1 つの User Pool を全テナントで共有する Pooled モデルです。
ここからテナントごとに App Client を作る App Client per tenant 方式に寄せました。
callback / logout URL を自テナントのオリジンに限定し、他テナントのドメインへリダイレクトさせないためです。
App Client の作成は packages/cdk/lib/constructs/auth.ts の tenantClients ループで行っています。
// テナントごとに App Client を作る。callback はそのテナントのオリジンに限定。
const clients = new Map<string, cognito.UserPoolClient>();
for (const tenant of props.tenantClients) {
const client = this.userPool.addClient(`AppClient-${tenant.tenantId}`, {
userPoolClientName: `tenant-${tenant.tenantId}`,
authFlows: { userSrp: true },
oAuth: useHostedUi
? {
flows: { authorizationCodeGrant: true },
scopes: [/* ... openid / email / profile ... */],
callbackUrls: [...tenant.callbackOrigins],
logoutUrls: [...tenant.callbackOrigins],
}
: undefined,
// ...
});
clients.set(tenant.tenantId, client);
}
this.userPoolClients = clients;
callbackUrls / logoutUrls にそのテナントの callbackOrigins(自サブドメインなど)だけを渡し、他テナントのドメインは混ぜません。
これでテナント(=クライアント)単位で、分離をしています。
フロントは自テナントの clientId を実行時に取得する
App Client をテナントごとに分けたので、フロントは「今アクセスしているホストに対応する clientId」を知る必要が出てきます。
ところが SPA は単一 dist を全テナントへ配信するため、全てのテナントごとの clientId を載せることができません。
そこで、CloudFront Function が /tenant-config.json を Host から動的生成して返す構成にしました。
packages/cdk/lib/functions/tenant-config.js の handler が、Host からテナントを解決し、そのテナント分だけを早期レスポンスで返します。
function handler(event) {
var request = event.request;
var host = request.headers.host ? request.headers.host.value : '';
var baseDomain = '__BASE_DOMAIN__';
var tenantConfig = __TENANT_CLIENT_MAP__;
var suffix = '.' + baseDomain;
var tenant = '';
if (host.length > suffix.length && host.slice(-suffix.length) === suffix) {
var label = host.slice(0, host.length - suffix.length);
// 単一ラベルのみ許可(さらにネストしたサブドメインは弾く)
if (label.length > 0 && label.indexOf('.') === -1) {
tenant = label;
}
}
var cfg = tenant && tenantConfig[tenant] ? tenantConfig[tenant] : null;
if (!cfg) {
return { statusCode: 404, /* ... "Unknown tenant" ... */ };
}
// 返すのは「このテナント1件」の公開設定のみ。
var body = JSON.stringify({
tenantId: tenant,
userPoolId: cfg.userPoolId,
userPoolClientId: cfg.userPoolClientId,
hostedUiDomain: cfg.hostedUiDomain,
});
return { statusCode: 200, /* ... */ body: body };
}
返すのは Host に対応する 1 テナント分だけで、全テナントのマップは返しません。
仮に Host を偽って別のサブドメインを名乗られても、そのテナントの公開設定が返るだけです。
そもそも返しているのは clientId など秘密でない値(public client は client secret を持たない)なので、他テナントの設定を引ければ困る、という性質のものではありません。
全テナントのマップを返さないのは、テナント一覧をまとめて列挙されることでを防ぐための配慮です。
多層防御として Authorizer でも aud を突合
App Client を分けたことで、 API の認可側にも 1 段防御を足せます。
packages/authorizer/src/index.ts で、tenantId 突合の直後に aud(トークンを発行した App Client)も突合しています。
const payload = await verifier.verify(token);
const jwtTenantId = payload[TENANT_CLAIM];
// (1) JWT 由来のテナントIDと Host 由来のテナントIDが一致しなければ拒否。
if (typeof jwtTenantId !== 'string' || jwtTenantId !== hostTenantId) {
return deny(event);
}
// (2) テナント↔clientId の突合。トークンを発行した App Client が、その
// テナントに割り当てられた App Client か検証する。
const expectedClientId = TENANT_CLIENT_MAP[jwtTenantId];
if (!expectedClientId || payload.aud !== expectedClientId) {
return deny(event);
}
仮に別テナントの App Client で発行されたトークンが来ても、aud が一致しなければ (2) で弾けます。前作の tenantId 突合に加えた多層防御ですね。
補足:App Client を増やして大丈夫?(クォータ)
テナントごとに App Client を作ると数が増えます。App clients per user pool のクォータはデフォルト 1,000 で、緩和申請で引き上げられます。
テナントが 1,000 を超えるフェーズに入るまでは気にしなくてよく、超えても上限緩和で対応できます。App Client 分離は現実的な選択肢かなと思います。
権限がないと Forbidden ページへ(やったこと③)
App Client を分けても、フロント部分のテナント分離はまだ不十分です。
同一 User Pool 内では Hosted UI のセッション Cookie が共有されます。
tenant-a でログイン済みのユーザーが tenant-b の Hosted UI に来ると、Cookie が効いて再入力なしでトークンが発行されます。
API は Authorizer が 403 で弾きますが、画面遷移まで塞ぐには、フロント側でも tenantId を突合する必要があります。これを確実に潰すのが、ログイン後の tenantId 一致チェックです。
なお、ここで突合する ID トークンの custom:tenantId は、pre-token-generation トリガーが発行時に注入したものです。ユーザーが後から書き換えて別テナントを名乗れないよう、この属性は mutable: false(不変)で定義しています。
判定は packages/web/src/lib/auth.ts の getAuthStatus で行います。
認証状態を 3 値(authenticated / unauthenticated / wrong-tenant)で返すようにしました。
トークンの custom:tenantId と、やったこと①で解決した現在ホストのテナント(getCurrentTenantId())を突合しています。
export type AuthStatus = 'authenticated' | 'unauthenticated' | 'wrong-tenant';
export async function getAuthStatus(): Promise<AuthStatus> {
try {
await getCurrentUser();
} catch {
return 'unauthenticated';
}
const session = await fetchAuthSession();
const tokenTenantId = session.tokens?.idToken?.payload?.[TENANT_CLAIM];
const currentTenantId = getCurrentTenantId();
if (
!currentTenantId ||
typeof tokenTenantId !== 'string' ||
tokenTenantId !== currentTenantId
) {
return 'wrong-tenant';
}
return 'authenticated';
}
wrong-tenant の場合は signIn に行かせず、/forbidden へ逃がします。
beforeLoad: async () => {
const status = await getAuthStatus();
if (status === 'unauthenticated') {
await signIn();
} else if (status === 'wrong-tenant') {
throw redirect({ to: '/forbidden' });
}
},
/forbidden の ForbiddenPage は認証ガードを通さない(誰でも表示できる)ルートにしてあります。
今開いているサイトのテナント名、そしてサインアウトボタンを出しています。サインアウトは Cognito の /logout へ遷移して Hosted UI の Cookie を消すので、正しいテナントのアカウントでログインし直せます。
動かしてみる
正常系と別テナントの 2 パターンを確認します。
正しいテナントでログイン
tenant-a に所属するユーザー(custom:tenantId=tenant-a)で tenant-a.example.com を開きます。起動時に /tenant-config.json が tenant-a の clientId を返します。
Hosted UI でログインすると、トークンの custom:tenantId と現在ホストが一致するのでダッシュボードが表示されます。/api/me も前作どおり 200 が返ります。
別テナントのサイトに入ろうとする
同じ tenant-a のユーザーで、今度は tenant-b.example.com を開きます。
Hosted UI の Cookie が共有されているため、再入力なしでトークン自体は得られてしまいます。ですが、トークンの custom:tenantId は tenant-a のまま。フロントの一致チェックで wrong-tenant と判定され、/forbidden に飛ばされて画面には入れません。
まとめ
前作の API 編に続き、フロント/認証もサブドメイン識別の流儀でテナント分離できました。やったことを 3 つに整理すると、次のとおりです。
- ① サブドメイン(
{tenant}.example.com)でテナントを識別し、起動時に自テナントの設定を実行時解決する - ② テナント分離を App Client 単位で行い、callback/logout をテナントのオリジンに限定。Authorizer で
audも突合して多層防御にする - ③ ログイン後にフロントで
custom:tenantIdを突合し、別テナントなら Forbidden ページへ誘導する
今回の気づきは、App Client を分けても Hosted UI を利用するとログイン状態が継続されてしまうところでした。


