
顧客専用サブドメインとホワイトラベル ― テナントの“真実源”はJWTではなくHost
平原 典彦 — Tesseraを開発しているエンジニア。ヘッドレスCMSとAI連携に関心があります。
制作会社がヘッドレスCMSでサイトを作り、コンテンツの更新は顧客に任せる——よくある座組みです。前回は、その顧客(非エンジニア)が迷わず使えるように、編集者ロールの管理画面を絞り込む話を書きました。今回はその続きで、顧客に「自分たちの管理画面」として渡すための、顧客専用サブドメインとホワイトラベル(ロゴ・名称の差し替え)の設計です。
見た目の話に見えて、実体はマルチテナントの認証と、DNS/SSLの泥臭い話でした。とくに面白かった(ハマった)2点——テナントの真実源をどこに置くかと、Cloudflareを使いながらワイルドカードSSLをどう出すか——を中心に記録します。
何を作ったか
Paidワークスペースは、顧客専用の入口を持てます。
- 顧客は
<slug>.app.tesseracms.comの専用ログイン画面からアクセスする - ログイン画面と管理画面のロゴ・プロダクト名を差し替えられる(未設定なら "Tessera")
- 顧客はそのワークスペースだけにロックされる(他ワークスペースは見えない・切替もない)
- 自己新規登録・OAuthは出さない(顧客は招待制)
制作会社は開発者として本体(tesseracms.com)で構築し、顧客には <slug>.app.tesseracms.com を渡す。顧客から見れば、そこは「自分たちのCMS」です。
テナント分離:真実源をJWTではなくHostに置く
顧客サイトで最初に効いてくるのが、このサブドメインは、このワークスペースの人しか入れないという保証です。ここを間違えると、顧客Aのサブドメインで顧客Bのデータが見える——最悪のテナント越境になります。
素直に考えると、こう作りたくなります。「ログイン時にセッション(JWT)へworkspaceIdを焼き込み、以降はそれを信じる」。実際、既存のワークスペース切替はこの方式でした。アクセストークンにworkspaceIdとroleが入っていて、各ページはそれを読む。
ところが、この方式はサブドメインと相性が悪いことに気づきました。理由はサイレントリフレッシュです。
Tesseraのアクセストークンは15分で失効し、裏でリフレッシュトークンから再発行します。その再発行処理は、トークンのworkspaceIdをユーザーの「既定ワークスペース」から引き直して署名し直していました。つまり——顧客サブドメインでワークスペースAのセッションを持っていても、15分後の自動リフレッシュで、ユーザーの既定ワークスペース(たとえばB)に勝手に戻る。トークンのworkspaceIdは、信頼できる真実源ではなかったのです。
ここでリフレッシュ処理を作り替えることもできましたが、既存の切替の挙動に広く絡むので、別の答えを採りました。顧客サブドメイン上では、ワークスペースはHostが決める。
- セッション(トークン)は「誰か」だけを証明する
- 「どのワークスペースか」は、リクエストの
Hostヘッダ(<slug>.app.tesseracms.com)から決める - サーバー側のセッション解決(
requireSession/ HonoのgetSessionFromHono)が、トークンのworkspaceIdではなく、Hostのslugからワークスペースを引き、そのワークスペースへの所属を検証してworkspaceId/roleを差し替える - 非メンバーなら弾く(ログインも、セッション解決も)
擬似コードにするとこうです。
// サブドメイン上では Host が真実源
const sub = await resolveSubdomainSession(session.userId, host);
if (sub.kind === "no_access") redirect("/login?error=no_access"); // 非メンバー
if (sub.kind === "member") {
return { userId: session.userId, workspaceId: sub.workspaceId, role: sub.role };
}
// apex(通常ドメイン)は従来どおりトークンの workspaceId を使う
こうすると、リフレッシュでトークンのworkspaceIdが既定に戻ろうと関係ありません。ページも、そのサブドメインが叩く/api/*も、すべてHostからワークスペースを導くので、テナントが混ざらない。さらにCookieはホスト単位(<slug>.app.tesseracms.comごと)に分かれるので、セッションそのものもサブドメイン間で独立します。
「トークンに状態を焼き込む」より「リクエストの文脈(Host)から毎回導く」ほうが、この場合は堅牢でした。真実源をどこに置くか——地味ですが、マルチテナントの設計で一番効いた判断でした。
DNS/SSLの落とし穴:ワイルドカード証明書とCloudflare
もう一つの山が、*.app.tesseracms.com のHTTPSです。ここは完全にインフラの話で、素直にやると詰まります。
やりたいのは「ワイルドカードで、どの顧客サブドメインも自動でHTTPSになる」。ところがワイルドカード証明書(*.example.com)は、DNS認証(DNS-01)でしか発行できません。HTTPファイル設置(HTTP-01)では出せない。DNS-01を自動でこなすには、証明書を出す側(今回はVercel)がそのゾーンのDNSを管理している必要があります。
だからVercelにワイルドカードを追加すると、こう言われます。「ネームサーバーをVercelに向けてください」。Vercelがゾーンを管理できれば、DNS-01を自動で通してワイルドカード証明書を出せる、というわけです。
でも、それはできませんでした。tesseracms.comのDNSはCloudflareで管理していて、メール(sendのMX/SPF/DKIM)・R2のmediaプロキシ・各種TXTがぶら下がっている。全ネームサーバーをVercelに移すと、これらが全部止まる。
かといって、単なるCNAME(* → Vercel)を置くだけでは、Vercelがゾーンを管理していないのでDNS-01が通らず、ワイルドカード証明書は出ません(Invalidのまま)。八方塞がりに見えました。
抜け道は、ゾーンの一部だけをVercelに委任することでした。
tesseracms.com全体ではなく、app.tesseracms.comというサブゾーンだけをVercelに委任する- Cloudflareに、
appのNSレコードを2つ足すだけ(app→ns1.vercel-dns.com/ns2.vercel-dns.com) - するとVercelが
app.tesseracms.comゾーンを管理下に置き、*.app.tesseracms.comのワイルドカード証明書を自動発行する - apex・
www・media(R2)・メールはappの下ではないので、Cloudflareのまま無傷
結果、顧客URLは <slug>.app.tesseracms.com(1階層増える)になりますが、一度この委任を設定すれば、以後どの顧客サブドメインも自動で有効・自動でHTTPS。顧客を追加するたびにVercelやDNSを触る必要がありません。「1階層の見た目」と「運用の手離れ」を天秤にかけ、後者を採りました。
(コード側は、環境変数ROOT_DOMAIN=app.tesseracms.comを見て、<slug>.app.tesseracms.comの先頭ラベルをslugとして取り出すだけ。ローカルは<slug>.app.localhostで同じ経路を検証できます。)
ブランディングの実装
ロゴとプロダクト名の差し替えは、ワークスペースに2つ値を持たせるだけです。
brandName:管理画面・ログインのワードマーク文字(未設定は "Tessera")logoAssetId:ロゴ画像のアセット参照
小さな注意が1つ。ロゴは、未認証のログイン画面にも出ます。だからロゴのアセットは**公開(public)**である必要があります(非公開だと署名付きURLが要り、ログイン前に出せない)。実装でも「publicアセットのときだけロゴURLを解決する」ようガードしています。
そして、ホワイトラベルはPaid限定の付加機能にしました。Freeでは設定できても適用されず、"Tessera"にフォールバックする。制作会社が「顧客ぶんを有料化する」動機になり、収益の柱(固定額サブスク)と噛み合います。将来「全員開放」に切り替えたくなっても、ブランド解決の関数でPaid判定を1本外すだけで済むように分離してあります。
まとめ
- 制作会社モデルの続き:顧客に「自分たちの管理画面」を渡すため、顧客専用サブドメイン+ホワイトラベル(Paid限定)を足した
- テナント分離の真実源はHostに置く:トークンの
workspaceIdはサイレントリフレッシュで既定WSに戻りうるので信頼しない。サブドメイン上ではHostからワークスペースを導き、所属を検証してworkspaceId/roleを差し替える。Cookieもホスト単位で独立 - ワイルドカードSSL×Cloudflare:ワイルドカード証明書はDNS-01が必須で、証明書を出す側にゾーン管理が要る。全ネームサーバー移管はメール・R2を壊すので、
app.tesseracms.comサブゾーンだけをNS委任。以後は顧客追加ゼロ手間で自動HTTPS - ブランディング:
brandName+logoAssetId。ロゴは未認証画面に出るのでpublicアセット限定。Paid限定で、判定は1箇所に寄せて将来の開放も容易に
「AIがCMSの構造を理解する」がTesseraの軸ですが、実際にプロダクトを回すと、その周りにはこういう地味な設計判断が積み重なります。今回は、見た目(ブランド)の裏にあった、テナントとDNSの話でした。