個人開発アプリで、LLMプロキシ(Supabase Edge Function)への不正アクセスを防ぐためにApp Attestを使おうとした記録です。先に結論:Deno ランタイムでインラインのApp Attest検証はやめておけ。証明書チェーン検証はマネージド(Firebase App Check)に寄せろ。
やりたかったこと
クライアント(iOS)からEdge Function経由でしかLLM(Anthropic)を呼べない構成にしています。レート制限とサーバ側の1日あたり支出上限はすでに入れていましたが、x-device-id ヘッダは偽装できるので、最後に「本物のアプリからのリクエストか」を確かめたい。iOSなら App Attest(DeviceCheck)です。素直に考えると、サーバ側でAppleの証明書チェーンを検証すればいい——そう思っていました。
第一案:インライン検証で全滅
ライブラリを入れて Deno で読み込もうとした時点で終わりました。
// これが起動時にクラッシュする
import { verifyAttestation } from "npm:node-app-attest";
// → 依存の pkijs が読み取り専用の globalThis に代入 → Deno worker が boot で落ちる
// esm.sh / ?target=denonext / npm: いずれも同じ setEngine エラー
回避を探って全滅した記録:
1. pkijs(node-app-attest の依存)→ globalThis 代入で boot クラッシュ
2. node:crypto の X509Certificate → Supabase の Deno ランタイムで未サポート
(stub/実機の起動テストで確認)
3. @peculiar/x509 → esm.sh / npm: どちらでもロード不可
4. 自前で ~600行の ASN.1/X.509 パーサを書く → 起動はしたが
実トークンの証明書チェーン検証に失敗(errorReason: cert_chain、3〜6ms)
Web検索を3回かけても、Supabase Edge Function で App Attest を検証している公開例は見つかりませんでした。 インラインのASN.1/X.509は、そもそもEdge Functionで保守すべき抽象レイヤーではなかった、というのが学びです。
現実解:Firebase App Check
App Check は、Googleが証明書チェーンの検証とApp Attestトークンの交換を肩代わりし、短命のJWTを発行してくれます。サーバ側は Firebase の JWKS に対して検証するだけ。約30行、pkijs も X509Certificate も不要。
import * as jose from "npm:jose@5";
const JWKS = jose.createRemoteJWKSet(
new URL("https://firebaseappcheck.googleapis.com/v1beta/jwks"),
);
const { payload } = await jose.jwtVerify(token, JWKS, {
issuer: `https://firebaseappcheck.googleapis.com/${projectNumber}`,
audience: `projects/${projectNumber}`,
});
// sub(App ID)を環境変数の期待値と照合。exp/nbf は jose が検証。
if (payload.sub !== expectedAppId) throw new Error("app_id_mismatch");
JWKSはコールドスタート時に1回だけ取得してインスタンス寿命でキャッシュ、検証自体は1リクエスト約1ms、DB往復もなし。第一案で必要だった attested_keys テーブル参照すら要りません。
消えたコード
第一案からの差分は、そのまま「負債が減った」ことを意味しました。
削除: verify-ios.ts / apple-attestation.ts / key-store.ts / postgres-key-store.ts
削除: attested_keys テーブル + RPC 3本
正味: テーブル1・サーバファイル4・約700行が消滅
トレードオフ(銀の弾丸ではない)
App Check の JWT は per-instance のクレームを持ちません(sub の App ID は全インストール共通)。だから前案のように「検証由来のキー」へ利用回数を紐づけることはできず、漏れたトークンは最大1時間有効です。
そこは多層で受けます。App Check によるアテステーション(端末の検証)は有効にしたうえで、最後の砦は「端末ごとの1日あたり利用上限+サーバ側の1日あたり支出上限」。App Check は無料枠の濫用を締める一枚であって、支出上限がすでに担保している以上の脅威カバレッジを足すものではない、と割り切りました。
教訓
- 暗号まわりの基盤選定は最初に。 Denoで証明書チェーン検証は地雷。あとからの方針転換は高くつく。
- 「単一ベンダー純度」より「適材適所」。 証明書チェーン検証をマネージドに寄せたのは正解でした。
アプリ本体(iPhone・基本無料):App Store