← ブログ一覧
技術ブログ

顧客専用サブドメインとホワイトラベル ― テナントの“真実源”は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を焼き込み、以降はそれを信じる」。実際、既存のワークスペース切替はこの方式でした。アクセストークンにworkspaceIdroleが入っていて、各ページはそれを読む。

ところが、この方式はサブドメインと相性が悪いことに気づきました。理由はサイレントリフレッシュです。

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.comHTTPSです。ここは完全にインフラの話で、素直にやると詰まります。

やりたいのは「ワイルドカードで、どの顧客サブドメインも自動で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に、appNSレコードを2つ足すだけ(appns1.vercel-dns.com / ns2.vercel-dns.com
  • するとVercelがapp.tesseracms.comゾーンを管理下に置き、*.app.tesseracms.comワイルドカード証明書を自動発行する
  • apex・wwwmedia(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
  • ブランディングbrandNamelogoAssetId。ロゴは未認証画面に出るのでpublicアセット限定。Paid限定で、判定は1箇所に寄せて将来の開放も容易に

「AIがCMSの構造を理解する」がTesseraの軸ですが、実際にプロダクトを回すと、その周りにはこういう地味な設計判断が積み重なります。今回は、見た目(ブランド)の裏にあった、テナントとDNSの話でした。

無料で始めるドキュメントを見る