暗号化したデータと復号鍵を同じ場所に置かない(ソースネクスト事例と Supabase の点検項目)
「暗号化して保存していた」のに平文で漏れる典型は、復号鍵に届くシステムが同じ侵入で破られるケース。パスワードは可逆暗号化ではなくハッシュにし、どうしても復号が要る外部 API キーなどは鍵を DB の外に分け、API の戻り値にも載せない。
事例から分かること
- ソースネクストの会員情報流出では、パスワードを暗号化して保存していたが、暗号鍵にアクセスできるシステムが不正アクセスを受け、元の文字列が判読できる状態で漏れたと同社が説明している。
- 直接の原因は会員情報取得 API の不正利用で、対象 2,251 万 9,960 件は API のログから特定した。API の戻り値にパスワードを含めていた理由は、ライセンス管理システムとの連携など、パスワードが必要な処理で使う API だったためとしている。
- 教訓は 3 つ。①ログイン用パスワードは復号できる形で持たない、②平文が必要な値を API のレスポンスに入れない、③暗号文と鍵が同じ侵入範囲にあると暗号化の意味がない。
対策
- ログイン用パスワードはハッシュにする: 復号鍵が存在しない形(Argon2id / bcrypt など)で保存する。パラメータの決め方は パスワードハッシュは「アルゴリズム+コストパラメータ」まで決める(Argon2id / scrypt / bcrypt / PBKDF2) を参照。他システム連携でパスワードが要るなら、パスワードを渡すのをやめて連携用のトークンや OAuth に置き換える。
- 復号が必要な秘密は鍵を分離する: 外部連携の API キーや Webhook URL のように送信時に平文が要るものは、AES-256-GCM などで暗号化して DB に置き、鍵は DB の外(実行環境のシークレット、KMS など)に置く。DB のダンプだけ漏れても復号できない状態を作る。
ts
import crypto from "node:crypto"; export function encryptSecret(plain: string, keyHex: string): string { const iv = crypto.randomBytes(12); // GCM は 12 バイト IV const c = crypto.createCipheriv("aes-256-gcm", Buffer.from(keyHex, "hex"), iv); const ct = Buffer.concat([c.update(plain, "utf8"), c.final()]); return `enc:v1:${iv.toString("hex")}:${c.getAuthTag().toString("hex")}:${ct.toString("hex")}`; }鍵をバックアップやリポジトリに入れない、アプリと DB を同じ環境に同居させない、も同じ考え方。
- レスポンスに戻さない: 登録済みのキーは本人の設定画面でもマスク表示にし、一覧取得の
SELECTから機密列を外す。ブラウザの開発者ツールに流れた時点で漏れたのと同じ。 - 大量取得を検知できるログを取る: 事例では API ログから対象件数を特定できた。逆に言えば、平常時から「1 クライアントが短時間に大量の会員情報を取った」を検知できれば被害を小さくできる。
Supabase を使っている場合の点検項目
SECURITY DEFINER関数の実行権限: PostgreSQL の関数は既定でPUBLICにEXECUTEが付き、publicスキーマにあるとPOST /rest/v1/rpc/<関数名>で誰でも呼べる。サーバー専用の関数はREVOKE EXECUTE ON FUNCTION ... FROM PUBLIC, anon, authenticated;のうえでservice_roleにだけ付ける。- RLS 判定用のヘルパーは
privateスキーマへ: PostgREST が公開するのはpublicスキーマだけなので、private.is_project_member(...)のように置けば RLS からは使えて外部の RPC からは見えない。 SELECT ... USING (true)を grep する: Security Advisor は書き込み系のWITH CHECK (true)は警告するが、SELECTのUSING (true)は意図的な全公開が多いため警告しない。profilesや招待テーブルが全件読める状態になっていないか、マイグレーションのCREATE POLICYを検索して確かめる。- 関数の
search_pathを固定する:SET search_path = public, pg_temp、より厳しくはSET search_path = ''にしてスキーマを完全修飾で書く。固定しないと同名オブジェクトのすり替えで権限昇格されうる。 - Leaked Password Protection を有効にする: Authentication の Attack Protection にある漏洩パスワード拒否(HaveIBeenPwned 連携)。
- 拡張機能を
extensionsスキーマに置く:pg_netなどがpublicに入っていると REST API に露出しうる。
出典
- @MalwareBibleJP · 2026-10-17
- ソースネクスト公式: sourcenext.com
- @yousukezan · 2026-10-17
- Zenn「暗号化したのに鍵まで抜かれるのはなんで?Supabase本番環境で見直したセキュリティ」(UK): zenn.dev