タイルサーバーが HTTP 200 を返しているのに、地図が全面「API KEY REQUIRED」になっていた

ステータスコード200のまま約2KBの透かし入りPNGを返していたCARTOタイルから国土地理院の淡色地図へ切り替えた記録と、15分削除の約束を守るためのSupabase Realtime設計。

#Leaflet#Supabase#セキュリティ

一時的な位置共有サービス「イマドコ(imadoko)」を本番環境で開いたところ、トップページ・ホスト画面・ゲスト画面のすべてで、地図の背景が「API KEY REQUIRED carto.com/basemaps/apikey」という透かし文字で埋め尽くされていました。

コードの差分を見ても、TypeScript の型検査や Supabase の RPC を確かめても、異常はどこにも見当たりません。さらには地図のタイルサーバー自体も HTTP 200 OK を返していました。そのため最初の自動確認では「タイル配信は正常」と誤判定してしまい、原因の切り分けに時間を取られました。

この調査をきっかけに、地図タイルの全面的な見直しと、データベースの不要カラムの整理、さらには「15分で消える一時的な位置共有」というサービスの前提を揺るがしかねない公開読み取り権限(RLS と Realtime)の設計問題まで、一気に手を入れました。

HTTP 200 のまま透かし画像を返すタイルサーバー

最初につまずいたのは、外部サービスの死活監視を HTTP ステータスコードだけで判断していたことでした。

イマドコでは Leaflet のベースマップとして、CARTO のラスタータイル(Voyager など)を API キーなしの URL で参照していました。ところが CARTO 側の仕様変更により、API キーを持たないリクエストに対して 401 や 403 のエラーを返すのではなく、ステータスコード 200 のまま「API KEY REQUIRED」と書かれた約 2KB の透かし入り PNG 画像を返すようになっていたのです。

確認した項目返ってきた結果実際の状態
タイル URL への HTTP リクエスト200 OK(image/png)正常に見える(偽陽性)
レスポンスのサイズ約 2KB(どの座標でもほぼ同一)通常の地図タイルより極端に小さい
画像の中身「API KEY REQUIRED」の透かし画像地図としてまったく機能していない

ネットワークタブや簡易的なヘルスチェックで「200 が返っているから通信は成功している」と思い込むと、こうしたサイレントな仕様変更を見落とします。外部から画像やタイルを取得する機能では、ステータスコードだけでなくバイトサイズや実際の描画結果まで確かめなければならないという教訓を得て、決定事項(D-008)としても明文化しました。

地図タイルの代替案と国土地理院タイルへの切り替え

CARTO のタイルが使えなくなったため、API キー不要で安定して使える代替の地図タイルを検討しました。候補に挙げたのは次の3つです。

候補特徴と構成採否と理由
日本全域 PMTiles(OSM 由来)約 1.59GB の単一ファイル。Range リクエスト対応、MapLibre 用スタイル付き見送り。作者が URL の直接参照を控えるよう記載しており Cloudflare R2 等での自前ホストが必須。また Leaflet から MapLibre への移行が必要で、ライセンス・商用利用条件の記載もページ上になかったため
Overture Maps構造化された地理空間データセット却下。データセットでありタイル配信サービスではないため、ベースマップ用途には遠回りすぎる
国土地理院(GSI)タイル淡色地図・標準地図などのラスタータイルを無料・キー不要で配信採用。既存の Leaflet 構成のまま URL を差し替えるだけで即座に復旧できる

K.Sakanoshita 氏が公開している日本全域の PMTiles(約 1.59GB)は非常に魅力的でしたが、イマドコの現在の描画基盤は Leaflet であり、MapLibre GL JS への全面移行と R2 バケットの運用を障害対応のタイミングで同時に行うのはリスクが大きすぎました。そこで、即座に安定した地図を取り戻すために MAP_STYLES から CARTO を外して国土地理院の「標準地図」に差し替え、デフォルトのスタイルをピンや文字が見やすい国土地理院の「淡色地図」に統一しました(D-007)。

使われていなかった 4 つの位置カラムの削除

地図の復旧と並行してデータベースのテーブル定義を棚卸ししたところ、sessions テーブルに初期設計の名残である lat・lng・guest_lat・guest_lng という4つのカラムが残っていることに気づきました。

初期のイマドコは「ホスト1人とゲスト1人」の1対1構造で作られており、その頃にセッション行そのものへ座標を持たせていた名残です。現在のマルチプレイヤー対応版では session_participants テーブルで全参加者の位置を管理しており、実際のコードと DB の状態を突き合わせると次のような無駄が起きていました。

  1. guest_lat と guest_lng はどこからも参照されていない。 本番・開発を含めて全行が NULL のまま放置されていました
  2. lat と lng は書くだけで一度も読まれていない。 位置更新用の RPC である update_location が、ホストの位置更新のたびに sessions テーブルの lat / lng にも同じ値を書き込んでいましたが、フロントエンドからは一切参照されていませんでした
  3. 不要な Realtime イベントが全員に飛んでいた。 ホストが移動するたびに sessions テーブルに UPDATE が走るため、セッションの終了検知のためだけに sessions を購読している全参加者のブラウザへ、毎回不要な変更通知が配信されていました

そこで update_location 関数を改修し、sessions テーブルへの座標書き込みを廃止しました(セッションの status が不一致のときだけ更新し、存在しない参加者 ID の場合は false を返すように変更)。そのうえで不要な4カラムを削除し、sessions テーブルを id・created_at・status の3カラムだけのスリムな構造に整理しました(D-010)。

「URL を知る人だけが見られる」と Realtime のジレンマ

データモデルの棚卸しを進める中で、さらに根本的なセキュリティとプライバシーの課題(O-001 / D-011)が浮かび上がりました。

イマドコは「アカウント登録不要で、共有 URL(10文字の nanoid、約 $2^60$ 通り)を知っている人だけが位置を共有できる」「位置データは更新が途絶えてから15分で pg_cron により自動削除される(D-003)」という設計を掲げています。書き込みに関しては SECURITY DEFINER の RPC と参加者ごとの秘密トークンで厳密に保護していました。

しかし読み取り側に目を向けると、Supabase Realtime の postgres_changes を使って参加者の位置や入退室を同期していたため、sessions と session_participants の両テーブルに対して匿名ロール(anon)の SELECT 権限が開け放たれていたのです。

postgres_changes はテーブルの RLS(Row Level Security)および SELECT 権限に従ってイベントを配信します。つまり、ブラウザに埋め込まれている公開の anon キーを取り出して REST API や GraphQL(pg_graphql)を直接叩けば、セッション ID を知らなくても、現在アクティブな全セッションの参加者の位置一覧を取得できてしまう状態でした。一方で、この一覧取得を塞ごうとして SELECT 権限を閉じると、今度は postgres_changes の購読が何も受け取れなくなり、リアルタイム同期が完全に止まってしまいます。

4 つの設計案の比較と「3日間保存される」罠

「セッション ID を知らない人には一切読ませない」「位置情報は15分を超えて残さない」「ログイン不要でリンクを開くだけですぐ使える」という3つの条件を同時に満たすため、4つのアーキテクチャ案(A〜D)を比較しました。

案仕組みの概要他セッションの覗き見位置データの保持期間判定と理由
A 案読み取りは SECURITY DEFINER の RPC に限定。位置同期はブラウザ間の Broadcast、入退室は DB トリガーからの「合図のみ」Broadcast に移し、テーブルの SELECT を完全に閉じる不可(ID が鍵になる)15分のまま(Broadcast は保存されない)採用(D-011)。既存の共有モデルと通信量を保ったまま一覧取得を完全に塞げる
B 案位置更新も含めてすべて DB トリガーから realtime.send で Broadcast する不可3日間残る(realtime.messages に蓄積)却下。DB からの Broadcast は内部テーブルに3日間保存されるため「15分で削除」の約束(D-003)に違反する
C 案Supabase Auth の匿名サインインを導入し、private チャンネルと RLS で制御する不可15分のまま見送り。同一 IP あたり1時間30件の制限や CAPTCHA 必須化があり、イベント会場や共有回線で「リンクを開くだけ」の強みが損なわれる
D 案現状維持(15分で消えることを理由に公開 SELECT を許容)可能(一覧取得できる)15分のまま却下。URL を知らなくても全員の位置が見えるため前提が成り立たない

この比較で最大の落とし穴だったのが B 案の realtime.send(Database Broadcast)の仕様です。データベースのトリガーから直接クライアントへ座標を Broadcast できれば、クライアント側での位置偽装も防げて実装もシンプルになるはずでした。しかし Supabase の公式ドキュメントと内部実装を詳しく調べると、DB からの realtime.send は realtime.messages テーブルに行を挿入する仕組みになっており、書き込まれたペイロードが3日間保持されることが判明しました。ここに緯度・経度や参加者名を載せてしまうと、表のテーブルから15分で消しても裏のログに3日間位置履歴が残り続けてしまいます。

また、C 案の「匿名サインイン(Anonymous Sign-Ins)」も一見すると画面上のログイン操作なしで導入できそうでしたが、ドキュメントを精査すると auth.users にゴミデータが溜まり続けるだけでなく、IP アドレスごとに1時間あたり30回というレート制限が既定でかかっていました。待ち合わせやイベント会場など、同じキャリア回線や Wi-Fi の共有 IP から複数人が同時にリンクを開いた瞬間にゲストが弾かれてしまうため、「リンクを送るだけで誰でもすぐ使える」というイマドコ最大の強みと相性が悪かったのです。

採用したハイブリッド同期アーキテクチャ

最終的に採用した A 案では、データの「読み取り」「高頻度の位置同期」「低頻度の状態変更」をそれぞれ別の経路に切り分けました。

  1. スナップショット取得専用の RPC(get_session_snapshot)。 テーブルの SELECT 権限を anon と authenticated から完全に剥奪し、代わりに SECURITY DEFINER で動く get_session_snapshot(p_session_id) を新設しました。セッション ID を渡したときだけ、そのセッションの状態(status)、サーバー時刻(server_time)、参加者一覧を JSON で返します
  2. 高頻度の位置更新はブラウザから直接 Broadcast。 各参加者のブラウザは update_location RPC で DB に位置を書き込んだ直後、トピック名 session:{id} の public チャンネルへ自分の座標を Broadcast します。クライアント発の Broadcast は DB に保存されないため、15分削除のポリシーを完全に守れます
  3. 入退室・改名・終了は DB トリガーから「中身のない合図」だけを送る。 参加や退室などが起きたときは、DB トリガーの notify_session_change から realtime.send で { kind: "session_participants:delete" } のようなイベント種別だけを送信し、位置や名前は一切載せないようにしました。さらに public チャンネルは誰でも偽のイベントを送れるため、受信側のクライアントはこの合図の中身をそのまま信用せず、合図を受け取ったら必ず get_session_snapshot を呼び直して正しい状態を取り直す設計にしています

この仕組みに移行したうえで、公開 SELECT ポリシーの削除、revoke select、さらには既定で残っていた TRUNCATE / REFERENCES / TRIGGER 権限の剥奪(D-016)まで段階的に適用しました。適用後に匿名キーで REST API と GraphQL を叩くと、3テーブルすべてで permission denied (401) が返り、スキーマからもテーブルが完全に隠れたことを確認できました。

運用して初めて見えた「Realtime 停止明け 5 秒間の空白」

さらにこの新しい同期方式を本番環境とヘッドレス Chrome(CDP による複数ブラウザ・位置情報エミュレーション)で繰り返し通し検証していたところ、13回中2回だけ「ゲストが退室したのにホストの画面からピンが20秒以内に消えない」という現象に遭遇しました(O-008)。

DB のログを見ると退室処理も realtime.send も確実に成功しており、単体で測れば約 0.1 秒で届く合図が、なぜかその回だけホストに届いていませんでした。Supabase の Realtime ログを突き合わせてようやく判明した原因は、**「接続者がいない状態が約10分続くと Realtime テナントが一時停止し、次の接続で再起動してから DB レプリケーション(realtime.messages の配信)が立ち上がるまでに約5秒の空白時間がある」**という挙動でした。停止明けの最初のユーザーがページを開き、5秒以内にすぐ入退室が起きると、その間の DB からの合図だけが闇に消えていたのです。

そこでクライアント側に「チャンネル接続から 7秒後 と 15秒後 に追加でスナップショットを取り直す」という対策(D-015)を入れました。実際に11分間待って Realtime を休眠させてから再検証したところ、最初の5秒間で4件の合図が失われる現象を再現しつつ、7秒後の自動取り直しによってホスト画面にわずか 3.9秒 で退室が反映されることを確認できました。

地図タイルが 200 OK の裏で透かし画像に変わっていた小さな異変から始まった調査でしたが、結果として外部依存の検証方法、不要な DB カラムの掃除、そして「URL を鍵にするエフェメラルな共有サービス」にふさわしいアクセス制御と同期基盤まで、システム全体の信頼性を一段引き上げることができました。

このノートに出てくるプロダクト

ほかのノート

ノートの一覧に戻る