壁紙アプリで長く使っている外部サービスの鍵を差し替えていた夜、手が止まりました。新しい鍵はもう発行できております。分からなかったのは、古いほうをいつ止めてよいのか、でした。
理由は単純でした。その鍵がどのアプリの、どの層に入っているのかを、私自身が思い出せなかったのです。サーバーにだけ置いてあるのなら、差し替えたその場で古い鍵を無効にできます。配信済みのアプリの中に埋まっていたのなら、更新が行き渡るまで無効にできません。
Rork で作り始めた方からいただく質問も、形を変えると同じところに行き着きます。「OpenAI の鍵はどこに入れればよいですか」という問いの本体は、置き場所の名前ではなく、あとから自分の意思で取り消せるかどうかなのです。
Rork の公式ドキュメントは、外部 API をつなぐ方法として二つを挙げております。Rork の環境変数と、Supabase の Edge Function に登録するシークレットです。どちらが正しいという話ではありません。取り消しの効き方が違うだけなのです。
アプリに配った鍵は、こちらの都合では止まりません
Rork Pro が書き出すのは React Native と Expo のプロジェクトです。Expo には環境変数の仕組みがあり、名前の先頭に EXPO_PUBLIC_ が付いた値は、ビルドのときにコードの中へそのまま埋め込まれます。
埋め込まれた値は、アプリを配ったあとも文字列として残ります。暗号化ではありません。手元の端末にあるアプリの中身を開けば読める、という性質のものです。
ここで効いてくるのが差し替えの速さです。サーバーに置いた鍵であれば、管理画面で値を書き換えて古いほうを無効にすれば、それで終わります。アプリの中に埋め込んだ鍵は、新しいビルドを作り、審査を通し、利用者の端末に更新が行き渡るまで、古いほうを生かしておかなければなりません。
個人でアプリを出しておりますと、審査の待ち時間だけはこちらの都合で縮まらないことを毎回のように思い知ります。鍵の事故は、こちらが待っているあいだも進みます。
Rork が案内している置き場所は二つあります
一つめが Rork の環境変数です。公式ドキュメントでは「Supabase のようなバックエンドにつながなくても API を使えるようにする、Rork ネイティブの仕組み」と説明されております。変数を追加し、鍵の内容が分かる名前を付け、値として鍵を入れます。そのうえで「この edge function を使って」と Rork に指示する、という流れです。
二つめが Supabase の Edge Function です。先に Supabase をつなぎます。手順としては、Supabase 側で Publishable key と Project の URL をコピーし、チャットに「Connect Supabase」と貼ります。そのあと Edge Functions の画面でシークレットに名前と値を登録し、関数の側からその名前で読み出します。
ここは混同が起きやすいところですので、一度だけ線を引いておきます。Publishable key と Project の URL は、アプリの中に入ってよい値です。守っているのは鍵の秘匿ではなく、テーブルごとに読み書きを制限する行レベルセキュリティの設定のほうなのです。この二つを「秘密の鍵」と思い込むと、本当に隠すべき鍵の置き場所の判断まで狂います。
受け皿としてもう一つ、Rork Backend があります。Rork の FAQ では、サードパーティの API を安全に呼び出すサーバーレス関数であり、API キーはサーバーに留まってアプリのバンドルには入らない、有料プランの利用者向けにホストしている、と説明されております。裏を返せば、無料プランで試している段階ではこの受け皿がありません。そこで急いで鍵を入れようとすると、入る先はアプリの中しか残らないのです。
もう一つ、先に確かめておくと悩みごと消える道もあります。チャット・音声の文字起こし・画像生成といった AI 機能については、有料プランでは Rork 側が AI をホストするため自分の鍵が要らない、と FAQ に書かれております。自前の鍵を配る必要が本当にあるのかを、置き場所を決める前に一度疑ってみてください。
| 置き場所 | 鍵が最終的に届く先 | 差し替えたときの効き方 | 前提 |
|---|---|---|---|
アプリに直接(EXPO_PUBLIC_ など) | 利用者の端末 | 新しいビルドと審査を待つことになる | 公開されてよい値のみ |
| Rork の環境変数+Rork Backend | サーバー(Rork がホスト) | 登録し直せばその場で切り替わる | 有料プラン |
| Supabase の Edge Function のシークレット | サーバー(自分の Supabase) | シークレットを更新して関数を再デプロイ | Supabase のアカウントと請求は自分持ち |
| Rork がホストする AI 機能 | 自分の鍵を持たない | 該当なし | 有料プラン・用途が合う場合 |
私が引いている線は「使われすぎたとき、誰に請求が来るか」
安全かどうかという言葉は、慣れていないうちは判断の役に立ちません。私は代わりに、請求書の宛先で三つに分けております。
一つめは、公開されることが前提の値です。Supabase の Publishable key と Project の URL がここに入ります。アプリの中にあって構いません。ただし行レベルセキュリティを設定していない状態で配ると、テーブルの中身がそのまま外から読める状態になります。
二つめは、使った分だけ課金される鍵です。生成 AI、地図、翻訳、音声合成。ここが最も痛いところで、抜かれた鍵は他人の手で使われ、請求はこちらに届きます。請求書がこちらに届く鍵は、こちらが止められる場所にしか置きません。
三つめは、管理者の権限を持つ鍵です。Supabase の service_role に相当するものや、決済サービスの秘密鍵がここに入ります。サーバー側の中でも、関数からしか読めない場所に限ります。私が運用しているサイトも Cloudflare Workers の上で動かしておりまして、決済まわりの秘密の値はワーカー側の設定にだけ置き、画面を作るコードからは触れないようにしております。
二つめをサーバー側へ寄せると、副産物として使われ方を数えられるようになります。誰がいつどれだけ呼んだのかが自分の関数を通るからです。ここまで来たら、Rork アプリの AI コストを月 ¥50,000 から ¥5,000 に削減させた Cloudflare AI Gateway 設計で扱っている、上限と記録の話がそのまま効いてきます。
Edge Function に寄せるときの、いちばん小さい形
置き場所を決めたら、次は実物です。端末からはこの関数だけを呼び、プロバイダの鍵は関数の中だけで読みます。何を解決するコードなのかを一行で言うなら、鍵を端末に渡さずに外部 API の結果だけを返す中継です。
// supabase/functions/summarize/index.ts
// 端末からはこの関数だけを呼ぶ。プロバイダの鍵は関数の中だけで読み、応答には含めない
const API_KEY = Deno.env.get("PROVIDER_API_KEY"); // Supabase の Secrets に登録した名前
const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, content-type",
};
function json(body: unknown, status: number) {
return new Response(JSON.stringify(body), {
status,
headers: { ...CORS, "Content-Type": "application/json" },
});
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: CORS });
// 鍵の未登録は「壊れた」ではなく「設定漏れ」として返す。原因の切り分けが早くなる
if (!API_KEY) return json({ error: "PROVIDER_API_KEY is not set" }, 500);
let text = "";
try {
({ text } = await req.json());
} catch {
return json({ error: "invalid JSON body" }, 400);
}
if (typeof text !== "string" || text.length === 0) return json({ error: "text is required" }, 400);
// 長さの上限は濫用よけであり、請求の上限でもある
if (text.length > 4000) return json({ error: "text is too long" }, 413);
const res = await fetch("https://api.example-provider.com/v1/summarize", {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
// 上流の生の応答をそのまま返さない。鍵やアカウントの情報が混じることがある
if (!res.ok) return json({ error: "upstream failed", status: res.status }, 502);
const data = await res.json();
return json({ summary: data.summary }, 200);
});期待する応答は {"summary":"..."} の一行です。うまくいかないときに返ってくるのは {"error":"PROVIDER_API_KEY is not set"} か {"error":"upstream failed","status":401} のどちらかで、前者はシークレットの登録漏れ、後者は鍵そのものの誤りだと切り分けられます。
なぜこの形にしているのかを書き添えます。鍵を読むのを一箇所に絞ると、差し替えるときに探し回らずに済みます。長さの上限を関数の入口に置くのは、濫用を止めるためであると同時に、請求の天井を自分で決めておくためです。そして上流のエラーをそのまま返さないのは、返却物に余計な情報が混ざるのを防ぐためなのです。
デプロイしたら、エンドポイントの URL を Rork のチャットに渡します。
このEdge Functionを使って、入力した文章の要約を取得してください。
エンドポイントは https://<project-ref>.supabase.co/functions/v1/summarize です。
本文は JSON の text フィールドで送り、応答の summary を画面に表示してください。公式ドキュメント自身が、この経路には試行錯誤が要ると書き添えております。データが読み取れない、関数が落ちる、鍵の打ち間違い——最初の一本は通らない前提で見ておくと、気持ちが折れにくくなります。私も最初のうちは一度で通そうとしすぎて、通らないたびに構成のほうを疑っておりました。実際には、打ち間違いと再デプロイ忘れが大半でした。
置いたあとに、自分の目で確かめる三つのこと
置き場所を決めただけでは終わりません。思ったところに入っているかを、一度だけ自分で見ておきます。
一つめは、書き出したコードを検索することです。有料プランでは GitHub 連携でコードを双方向に同期できますので、手元に落として次のように探します。
# 鍵そのものや、鍵を入れていそうな名前が残っていないかを見る
grep -rniE "api[_-]?key|secret|token" . --include="*.ts" --include="*.tsx" | grep -v "YOUR_API_KEY"
# 端末まで配られる名前(先頭が EXPO_PUBLIC_)を数える。ここに機微な鍵の名前があれば置き場所が違う
grep -rn "EXPO_PUBLIC_" . --include="*.ts" --include="*.tsx" --include="*.env*"二つめは、名前ではなく値で探すことです。鍵の先頭の数文字をそのまま検索して、一件も出てこないことを確かめます。名前を変えて隠したつもりでも、値が残っていれば意味がありません。
三つめが、いちばん確かな方法です。検証用の鍵を一本だけ無効にして、アプリの見た目が変わらないかを見ます。サーバー側だけを見ている作りであれば、関数の側がエラーを返し、画面には要約が出ないという形で現れます。端末が直接呼んでいた場合も同じように失敗しますので、ここで分かるのは「どこで失敗したか」です。関数のログに失敗が残っていれば、鍵は関数の中にいます。ログに何も来ないまま端末側だけが失敗したのなら、鍵は端末にいます。
同じ考え方で端末とサーバーの役割を引き直した記録として、3分の動画だけが失敗する — Rork の動画解析をワーカー経由から端末直送に引き直すも残しております。データの重さと鍵の重さは別物で、置き場所の答えも別になります。
今日の一手
いま動いているプロジェクトを一つだけ開いて、外部サービスの鍵が何本使われているかを書き出すところから始めていただければと思います。本数さえ分かれば、一本ずつ「これは請求がこちらに来る鍵か」と問い直すだけで、置き場所はおのずと決まります。
私も、差し替えの夜に手が止まってから、この棚卸しを先にやる習慣をつけました。いま思えば、あのとき迷っていたのは置き場所そのものではなく、取り消せる形を自分が知らなかっただけなのかもしれません。最後までお付き合いくださり、ありがとうございました。