Blogへ戻る

Language: 日本語

クエリは 3.6ms、API は 304ms。外に出ていたのは認証だった

API のレスポンスが遅いとき、みなさんならまず何を疑いますか?

API のレスポンスが遅いとき、みなさんならまず何を疑いますか?

「どうせ N+1 問題か、インデックスが効いてないスロークエリだろう」と思って DB を見に行くのがエンジニアの自然な反射神経だと思います。私も完全にそのつもりでした。

しかし、Cloud Trace で遅いリクエストの内訳を開いてみたところ、予想外の数字が並んでいました。

  • API 全体の処理時間: 約 304ms
  • 発行された SQL(SELECT): 3.6ms

DB は 3.6ms で一瞬で結果を返していました。文句のつけようがない爆速です。

「え、じゃあ残りの 300ms はどこで消えているの……?」とトレースの span をドリルダウンしてみたところ、コントローラーに入る直前の外部 HTTP POST に 217ms も吸われていました。

遅さの正体は SQL でもインフラの詰まりでもなく、全リクエストで律儀に走っていた Firebase の認証処理 でした。

この記事で伝えたい結論はシンプルです。 「コーディングエージェントが出してきた『安全にする PR』を無邪気に信じると、全リクエストに外部 I/O が乗るような大事故が起きる」「だからこそ当て推量ではなく、分散トレースで事実を可視化してエージェントに正確なデータを渡す Observability が不可欠である」 ということです。

得られること

  • Firebase Auth の即時失効の罠: checkRevoked=true による毎回外部通信(200〜300ms)の代償と、自前 DB を使ったゼロ外部通信での解決策がわかる
  • エージェント時代のセキュリティ PR 監査: 「安全になる」修正がシステム全体のパフォーマンスを破壊する典型パターンへの警戒感が持てる
  • Observability の実効性: DB のスロークエリを疑う前に、分散トレースで「時間の内訳」を客観的に把握するアプローチが学べる!

エージェントの「安全なPR」を踏んだ経緯

なぜただのトークン認証で、毎回 200ms 超の外部通信が飛んでいたのでしょうか。

実はこのアプリ、最近流行りのバイブコーディング(AI 主導)で開発されていて、少し前にコーディングエージェントからこんなセキュリティ修正 PR が届いていました。

「ログアウト後も盗まれた ID トークンが有効期限(最大1時間)まで使えてしまう脆弱性を修正しました」

この課題自体はごく真っ当です。Firebase の ID トークンはステートレスな JWT なので、ログアウトしても有効期限(最大 1 時間)が切れるまではサーバー側で通ってしまいます。

エージェントが提案してきた修正は、極めてシンプルでした。

// ログアウト時: リフレッシュトークンを失効
await admin.auth().revokeRefreshTokens(uid);

// サーバー認証時: 第2引数で失効チェックを有効化
const decodedToken = await admin.auth().verifyIdToken(token, true); // checkRevoked = true

セキュリティ修正で「これを入れればログアウト済みのトークンを確実に弾けます!」と書かれていると、人間側も「お、安全になるならヨシ!」と無邪気にマージしてしまいがちです。私も普通に通していました。

しかし、ここに大きな落とし穴がありました。

毎回外部通信が飛ぶ仕組み

通常、verifyIdToken(token, false) は Google の公開鍵を使って ローカルで署名検証するだけ なので、数ミリ秒もかからず完了します。

ところが、第 2 引数の checkRevokedtrue にした瞬間、Firebase Admin SDK は内部で毎回以下のような通信フローを走らせます。

クライアントからリクエスト受信

ローカルで公開鍵による署名検証(数 ms)

Firebase Authentication のバックエンド(accounts:lookup)へ HTTP POST
「このユーザーのトークンは失効していませんか?」(200〜300ms 💥)

ようやくコントローラーの処理へ

失効状態は Firebase 側のサーバーしか知らないため、毎回聞きに行くしかありません。 日本やアジアのリージョンから米国の Firebase エンドポイントを叩く構成だと、この 1 往復だけで軽く 200〜300ms かかります。

エージェントは「公式ドキュメント(セッションの管理)通りの安全なオプション」を置いただけですが、結果として 全エンドポイントの全リクエストに、毎回 200ms 超の外部 I/O が強制課金される という恐ろしい状態が完成していました。

アプリ側 DB で失効を判定する設計

「じゃあ checkRevoked = false に戻せば解決!」としたいところですが、それではログアウト後もトークンが使えてしまう元のセキュリティ問題に逆戻りです。安全性を落とすわけにはいきません。

そこで、「失効しているかどうかの判定権限(Authority)」を Firebase から自前の DB へ移す ことにしました。

Before(Firebase 照会)After(DB 失効境界)
トークン検証verifyIdToken(token, true)verifyIdToken(token, false)
失効の確認先Firebase の外部 APIアプリ側の DB(同一 VPC 内)
通常リクエスト時の通信毎回外部 HTTP 通信(200〜300ms)ローカルの JWT 検証のみ(0ms)

仕組みはとてもシンプルです。ユーザーテーブルに tokenRevokedBefore(失効日時)というカラムを 1 つ足すだけです。

  1. ログアウト / アカウント BAN 時:
    • DB の users.tokenRevokedBefore を現在時刻に更新する(書き込み)。
  2. 通常リクエスト時:
    • verifyIdToken(token, false) で署名だけをローカル検証し、トークン内の発行日時(auth_time)を取り出す。
    • auth_time < tokenRevokedBefore なら「ログアウト前の古いトークン」としてリクエストを弾く。

tokenRevokedBefore が更新されるのはログアウトと BAN の瞬間だけなので、DB への書き込み負荷はほぼ無視できます。 また、読み込みもアプリが普段行っているユーザー情報の取得やセッションチェックに相乗りできるため、余計なクエリは一切増えません。

これで、安全性を 1 ミリも落とすことなく、通常リクエストから外部通信を完全に消し去ることができました。

SSoT と UX 観点での別アプローチ

今回はバックエンドの DB に失効境界を持たせることで解決しましたが、冷静に考えると 「認証の SSoT(信頼できる唯一の情報源)が Firebase Auth と自前 DB に分散してしまう」 という設計上のトレードオフもあります。

システムの特性や要件によっては、以下のようなフロントエンド運用や UX レベルの別アプローチも十分に検討できます。

1. 認証チェック頻度の間引き(重要度に応じた使い分け)

すべての API で一律に checkRevoked = true を走らせるのではなく、「決済や個人情報変更などのクリティカルな操作のみ失効チェックを行い、通常の閲覧系 API はローカル署名検証(checkRevoked = false)で済ませる」 というアプローチです。閲覧系のレスポンスを爆速に保ちつつ、重要操作だけを厳格に保護できます。

2. 認証不要なデータの非同期取得と UX 最適化

今回問題になった統計データのように、「そもそもユーザーごとの厳密な個人情報ではないデータ」であれば、認証ミドルウェアを通過させずに先行してレスポンスを返す設計も有効です。フロント側で SWR や TanStack Query によるバックグラウンド取得を活用し、ユーザーを認証の同期通信で待たせない工夫が効きます。

Next.js や Cloudflare Workers などの BFF(Backend For Frontend)を挟んでいる構成なら、Firebase の Session CookiecreateSessionCookie)を使う手もあります。

徳丸浩氏が X の投稿 や khale 氏の解説記事(「JWT を localStorage に置くな」はなぜ言われるのか、Cookie 回帰までの時系列整理)で言及されている通り、「ブラウザ境界は XSS 耐性のある HttpOnly Cookie、内部サービス間はステートレスなトークン」 という役割分担(BFF によるトークン隔離)に寄せるアプローチです。

ブラウザ側を短命な Cookie セッションにし、ログアウト時は Cookie を破棄するだけに倒せば、バックエンドは自前 DB に失効状態を抱えることなく純粋なステートレス検証に専念できます。

計測データとコンテキストの重要性

このトラブルを通じて痛感したことが 2 つあります。

1 つ目は、「API が遅い=DB が重い」とは限らない ということです。 今回もし分散トレースが入っていなければ、「とりあえずインデックスを貼る」「クエリを書き直す」といった見当違いなチューニングに何時間も溶かしていたはずです。クエリが 3.6ms で終わっている事実は、トレースで内訳を見て初めてわかりました。

2 つ目は、コーディングエージェントに「勘」で修正を任せる怖さ です。 エージェントは「トークン失効をチェックしたい」という局所的な指示にはそれらしいコードを出してくれますが、実際のネットワーク遅延や毎リクエストの呼び出し頻度といった実行時コンテキストがなければ、システム全体の膨大な変数の中で大きく外してしまいます。

だからこそ、当て推量でプロンプトを投げるのではなく、「エージェント自身が Cloud Trace や実行ログを探索・観測し、実際のボトルネック(外部通信のオーバーヘッド)をコンテキストとして得られるように指示・環境を整えること」 が大切になります。

バイブコーディングでアプリを高速に立ち上げられる時代だからこそ、OpenTelemetry や Cloud Trace などの Observability(可観測性)を手元に揃え、AI が自律的に正しいコンテキストを掴めるようにしておくのが本当に大切だなと実感した一件でした。

参考リンク