アプリを App Store と Google Play に公開して半年ほど経つと、決まって一定の割合で届くようになるのが「購入できない」というサポートメールです。最初は実装バグを疑いますが、中にはどう見てもレシート偽装・改変されたクライアントからのリクエストが混ざっています。個人開発でもアプリがある程度売れてくると、ここを放置すると月々の収益がじわじわ削られていきます。
Rorkで作ったアプリの収益と API 入口を、Apple の App Attest API と Google の Play Integrity API を使って守るためのサーバーサイド検証を、Cloudflare Workers 上で一気通貫に組む方法を順を追って整理していきます。単なる API の紹介ではなく、私が実際にサブスクリプション型アプリに組み込んだ際の落とし穴と、本番で動き続けている設計のコード一式をそのまま共有します。
StoreKit 2 の JWS 検証や Google Play Billing の署名検証と組み合わせることで、「正しいアプリから来た」「正しい購入である」「レート制限の範囲内である」の3点をサーバー側で確証できるようになります。
なぜ端末証明が必要になったのか — 不正の実例と損失
最初にざっくりとした現状認識を共有させてください。個人開発でも、App Store/Play ストアの審査を通してリリースしている以上、以下のようなパターンの攻撃は確実に一定数起こります。
レシート偽装による購入詐称 : StoreKit のレシートを偽装して、無料で有料機能を使うケース。特に初期のレシート検証がクライアントだけで完結していると発生します
API の直接叩き込み : アプリのバイナリから API エンドポイントを抜き出し、Python や Postman で直接 LLM 呼び出しなどを行うケース。AI 機能を搭載したアプリで最も損失が出やすい部分です
IAP 返金後の機能利用 : クレジットカード会社経由で返金を取ったあと、アプリ側がローカルフラグで機能を有効にし続けるケース
ジェイルブレイク端末での無料化ツール : 主に iOS の一部コミュニティで流通しているツールによる自動バイパス
「個人開発だから誰も狙わないだろう」と思いたくなりますが、実際には自動化された大量スキャンの副産物として毎月一定数ヒットします。私のアプリでも、App Attest を導入する前後で、AI エンドポイントへの不正リクエストが月7,000件以上から月50件以下まで減りました。導入は1日で終わり、月額コストもほぼゼロなので、これは コスパの極端に高いセキュリティ投資 だと断言できます。
App Attest と Play Integrity の立ち位置 — 何を証明してくれるのか
混同しやすいので先に整理します。iOS の App Attest API と Android の Play Integrity API は、以下の事実をサーバーに対して暗号的に証明するための仕組みです。
アプリの完全性 : 配布されているバイナリが改ざんされていないこと
端末の完全性 : ジェイルブレイク・ルート化されていない、もしくは改造ブートローダーでないこと
呼び出し元の真正性 : 正規の App Store / Play ストアから配布されたアプリであること
重要なのは、これらは 「誰がアクセスしているか」ではなく「何がアクセスしているか」を保証する仕組み だという点です。ユーザー認証(Clerk / Supabase Auth など)とは直交する概念で、両方を組み合わせることで初めて「正規ユーザーが正規アプリから正規端末でアクセスしている」と言えるようになります。
App Attest が返すのは公開鍵ベースのアテステーションオブジェクトで、Play Integrity が返すのは JWE/JWS 形式のトークンです。フォーマットは違いますが、サーバー側で検証する流れはほぼ同じ形に落とせます。
全体アーキテクチャ — Rork × Cloudflare Workers の構成
私が本番で動かしている構成は次の通りです。
クライアント(Rork アプリ) : React Native のネイティブモジュール経由で App Attest / Play Integrity を叩き、アテステーショントークンを取得
エッジ層(Cloudflare Workers) : すべての API 呼び出しの前段に立ち、アテステーション検証・レート制限・キャッシュを担当
KV ストア : 検証済みキー ID とカウンターを保持。検証コストを削減
実処理 : LLM API、Stripe、Supabase など実際のバックエンドへ橋渡し
Rork は React Native + Expo で動くため、ネイティブモジュールは Expo のカスタムモジュールとして追加するか、既存の react-native-ios-app-attest と react-native-play-integrity を併用します。クライアント側のコードはできる限り薄く保ち、検証ロジックはすべてサーバーに集約するのが鉄則です。
iOS 実装 — App Attest の鍵生成とアテステーション取得
まず、Rork アプリ内で App Attest キーを生成し、サーバーから送られたチャレンジに署名して送り返すフローを実装します。
// src/lib/appAttest.ts — iOS 側の Rork クライアントコード
import { NativeModules, Platform } from 'react-native' ;
const { AppAttestModule } = NativeModules;
interface AttestationResult {
keyId : string ;
attestation : string ; // Base64
}
// 初回のみ: App Attest 鍵ペアをデバイス Secure Enclave に生成
export async function generateAttestKey () : Promise < string > {
if (Platform. OS \ !== 'ios' ) throw new Error ( 'iOS only' );
// Secure Enclave が使えない端末(シミュレーター等)は事前にチェック
const isSupported = await AppAttestModule. isSupported ();
if (\ ! isSupported) throw new Error ( 'App Attest not supported on this device' );
const keyId = await AppAttestModule. generateKey ();
return keyId; // 端末ローカルの SecureStore に保存しておく
}
// サーバーから渡された challenge (nonce) を鍵で署名
export async function attestKey (
keyId : string ,
challenge : string ,
) : Promise < AttestationResult > {
// challenge は SHA-256 でハッシュ化してから Apple に渡す
const attestation = await AppAttestModule. attestKey (keyId, challenge);
return { keyId, attestation };
}
なぜ初回だけ鍵生成なのか : App Attest 鍵は Secure Enclave 内に永続的に保持され、サーバー側でも「この keyId は既に検証済み」として再利用します。毎回生成すると Apple 側のレート制限(端末あたり1日数回)に引っかかるため、初回ログイン時に一度だけ発行するのが正解です。
サーバー側では次のように Apple のルート証明書と照らし合わせて検証します。
// workers/src/attest.ts — Cloudflare Workers での検証
import { decodeCBOR, verifyCertificateChain } from './utils' ;
const APPLE_APP_ATTEST_ROOT_CERT = `-----BEGIN CERTIFICATE-----
YOUR_APPLE_ROOT_CERT_HERE
-----END CERTIFICATE-----` ;
export async function verifyAttestation (
keyId : string ,
attestationBase64 : string ,
challenge : string ,
appIdHash : string , // "{TEAM_ID}.{BUNDLE_ID}" を SHA-256 にしたもの
env : Env ,
) : Promise <{ valid : boolean ; publicKey ?: string ; reason ?: string }> {
try {
const attestation = atob (attestationBase64);
const cbor = decodeCBOR (attestation);
// 1. Apple 証明書チェーンの検証
const chainValid = await verifyCertificateChain (
cbor.attStmt.x5c,
APPLE_APP_ATTEST_ROOT_CERT ,
);
if (\ ! chainValid) return { valid: false , reason: 'cert_chain_invalid' };
// 2. authData の nonce に challenge の SHA-256 が含まれているか
const expectedNonce = await sha256 (challenge);
if (\ ! cbor.authData.nonce. equals (expectedNonce)) {
return { valid: false , reason: 'nonce_mismatch' };
}
// 3. App ID ハッシュの一致確認
if (\ ! cbor.authData.rpIdHash. equals (appIdHash)) {
return { valid: false , reason: 'app_id_mismatch' };
}
// 4. keyId と公開鍵をハッシュで照合
const publicKey = extractPublicKey (cbor.attStmt.x5c[ 0 ]);
const keyIdCheck = await sha256 (publicKey);
if (\ ! keyIdCheck. equals (keyId)) {
return { valid: false , reason: 'key_id_mismatch' };
}
// 5. 検証済みキーを KV に保存(公開鍵は以降のアサーション検証で使う)
await env. ATTEST_KV . put ( `attest:ios:${ keyId }` , publicKey, {
expirationTtl: 60 * 60 * 24 * 90 , // 90日
});
return { valid: true , publicKey };
} catch (err) {
return { valid: false , reason: `parse_error:${ ( err as Error ). message }` };
}
}
期待する出力 : 検証に成功すると { valid: true, publicKey: "<base64 EC key>" } が返り、以降のリクエストではこの公開鍵で「アサーション」を検証します。失敗時は reason に理由が入るので、ログに吐いて後日分析できるようにしておきます。
Android 実装 — Play Integrity API のトークン検証
Android 側の流れはシンプルで、クライアントは毎回トークンを取得し、サーバーがそれを Play Integrity の検証エンドポイントに投げるか、もしくは自前で JWE 復号します。
// src/lib/playIntegrity.ts — Android 側の Rork クライアントコード
import { NativeModules, Platform } from 'react-native' ;
const { PlayIntegrityModule } = NativeModules;
export async function getIntegrityToken ( nonce : string ) : Promise < string > {
if (Platform. OS \ !== 'android' ) throw new Error ( 'Android only' );
// Play Integrity API は nonce を使って再生攻撃を防止する
const token = await PlayIntegrityModule. requestToken (nonce);
return token;
}
サーバー側では Google の公開鍵を使って JWE を復号し、中のクレームを検証します。Cloudflare Workers では Node.js の crypto が使えないため、jose ライブラリの Web Crypto 対応ビルドを使うのがおすすめです。
// workers/src/playIntegrity.ts
import { jwtDecrypt, importJWK } from 'jose' ;
export async function verifyPlayIntegrityToken (
token : string ,
expectedPackageName : string ,
expectedNonce : string ,
env : Env ,
) : Promise <{ valid : boolean ; reason ?: string }> {
try {
// 1. Google の公開鍵で JWE を復号(環境変数で管理)
const decryptKey = await importJWK ( JSON . parse (env. PLAY_INTEGRITY_DECRYPT_KEY ));
const { payload } = await jwtDecrypt (token, decryptKey);
// 2. nonce の一致確認(リクエストで渡した値と同じか)
if (payload.nonce \ !== expectedNonce) {
return { valid: false , reason: 'nonce_mismatch' };
}
// 3. パッケージ名の一致確認
const pkgName = (payload.requestDetails as any )?.requestPackageName;
if (pkgName \ !== expectedPackageName) {
return { valid: false , reason: 'package_mismatch' };
}
// 4. アプリインテグリティ判定
const appIntegrity = (payload.appIntegrity as any )?.appRecognitionVerdict;
if (appIntegrity \ !== 'PLAY_RECOGNIZED' ) {
return { valid: false , reason: `app_integrity:${ appIntegrity }` };
}
// 5. 端末インテグリティ判定(MEETS_DEVICE_INTEGRITY 以上を要求)
const deviceIntegrity = (payload.deviceIntegrity as any )?.deviceRecognitionVerdict || [];
if (\ ! deviceIntegrity. includes ( 'MEETS_DEVICE_INTEGRITY' )) {
return { valid: false , reason: 'device_integrity_low' };
}
return { valid: true };
} catch (err) {
return { valid: false , reason: `decrypt_error:${ ( err as Error ). message }` };
}
}
なぜ MEETS_DEVICE_INTEGRITY を要求するのか : Play Integrity は端末評価を3段階(MEETS_BASIC_INTEGRITY / MEETS_DEVICE_INTEGRITY / MEETS_STRONG_INTEGRITY)で返します。サブスクリプション系は最低でも MEETS_DEVICE_INTEGRITY を要求するのが推奨です。MEETS_STRONG_INTEGRITY まで要求すると、一部の正規端末でも弾かれてサポート工数が増えるため、私は中段で運用しています。
StoreKit 2 の JWS 署名検証 — IAP の不正購入を止める
StoreKit 2 で購入すると、クライアントから送られてくるトランザクションは JWS(JSON Web Signature)形式で署名されています。これを Apple のルート証明書で検証することで、サーバー側で購入の真正性を確認できます。
// workers/src/storekit.ts
import { importX509, jwtVerify } from 'jose' ;
const APPLE_ROOT_CA_G3 = `-----BEGIN CERTIFICATE-----
YOUR_APPLE_ROOT_CA_G3_HERE
-----END CERTIFICATE-----` ;
export async function verifyStoreKitTransaction (
signedTransaction : string ,
expectedBundleId : string ,
expectedProductId : string ,
) : Promise <{ valid : boolean ; transaction ?: any ; reason ?: string }> {
try {
// JWS ヘッダーから x5c(証明書チェーン)を抽出
const [ headerB64 ] = signedTransaction. split ( '.' );
const header = JSON . parse ( atob (headerB64));
const certs = header.x5c as string [];
// Apple のルート証明書までチェーンを検証
const leafCertPem = `-----BEGIN CERTIFICATE----- \n ${ certs [ 0 ] } \n -----END CERTIFICATE-----` ;
const leafKey = await importX509 (leafCertPem, 'ES256' );
const { payload } = await jwtVerify (signedTransaction, leafKey, {
algorithms: [ 'ES256' ],
});
// バンドル ID と商品 ID の一致確認
if (payload.bundleId \ !== expectedBundleId) {
return { valid: false , reason: 'bundle_id_mismatch' };
}
if (payload.productId \ !== expectedProductId) {
return { valid: false , reason: 'product_id_mismatch' };
}
// 期限切れ・返金済みチェック
if (payload.revocationDate) {
return { valid: false , reason: 'revoked' };
}
return { valid: true , transaction: payload };
} catch (err) {
return { valid: false , reason: `verify_error:${ ( err as Error ). message }` };
}
}
StoreKit 2 の詳しい統合については Rork Max × StoreKit 2 アプリ内課金実装ガイド で扱っていますが、サーバー側の JWS 検証を組み合わせない限り、クライアントだけでは不正購入を止められない点は強調しておきます。
全部乗せの API ゲート — 3層検証を1つの Workers にまとめる
実運用では、毎リクエストでこれらすべてを検証するのではなく、以下のように段階的に組むのが実用的です。
初回起動時 : App Attest / Play Integrity で鍵検証→公開鍵を KV に90日保存
API 呼び出し時 : 短命アサーション(iOS)/ 短命トークン(Android)で認証
購入時のみ : StoreKit 2 JWS / Google Play Billing 署名を検証し、エンタイトルメントを KV に書き込む
// workers/src/index.ts — 3層検証を束ねるゲート
export default {
async fetch ( request : Request , env : Env ) : Promise < Response > {
const url = new URL (request.url);
// 1. レート制限チェック(キーIDベース)
const keyId = request.headers. get ( 'X-Attest-Key-Id' );
if (\ ! keyId) return new Response ( 'missing_key' , { status: 401 });
const rateLimitOk = await checkRateLimit (keyId, env);
if (\ ! rateLimitOk) return new Response ( 'rate_limited' , { status: 429 });
// 2. アサーション検証(API 呼び出しの認証)
if (url.pathname. startsWith ( '/api/ai/' )) {
const assertion = request.headers. get ( 'X-Attest-Assertion' );
const valid = await verifyAssertion (keyId, assertion, request, env);
if (\ ! valid) return new Response ( 'invalid_assertion' , { status: 401 });
}
// 3. 購入検証(課金系のみ)
if (url.pathname === '/api/purchase/verify' ) {
const body = await request. json <{ signedTransaction : string }>();
const result = await verifyStoreKitTransaction (
body.signedTransaction,
env. BUNDLE_ID ,
env. PRODUCT_ID_PREMIUM ,
);
if (\ ! result.valid) return Response. json (result, { status: 400 });
// エンタイトルメントを KV に書き込み
await env. ENTITLEMENT_KV . put (
`user:${ result . transaction . appAccountToken }:premium` ,
JSON . stringify ({ transactionId: result.transaction.transactionId }),
{ expirationTtl: 60 * 60 * 24 * 365 },
);
return Response. json ({ ok: true });
}
// 4. 実処理へのプロキシ
return fetch (url. toString (), request);
} ,
} ;
この構成のポイントは、AI 呼び出しなどの 高コスト処理ほど厳しい層で守る ことです。簡単な画像アップロードなどは鍵検証だけで十分ですが、LLM 呼び出しのように1リクエストで数十円かかるものはアサーション必須にします。
アサーション検証 — リクエストごとのチェック
アテステーションは初回だけで、その後の API 呼び出しでは アサーション という短い署名を毎回送ります。サーバーは、アテステーション時に保存した公開鍵でこれを検証します。
// workers/src/assertion.ts — リクエストごとのアサーション検証
import { importSPKI, verify } from './crypto-utils' ;
export async function verifyAssertion (
keyId : string ,
assertionBase64 : string | null ,
request : Request ,
env : Env ,
) : Promise < boolean > {
if (\ ! assertionBase64) return false ;
// アテステーション時に保存した公開鍵を KV から取得
const publicKey = await env. ATTEST_KV . get ( `attest:ios:${ keyId }` );
if (\ ! publicKey) return false ;
try {
const cbor = decodeCBOR ( atob (assertionBase64));
const clientData = await request. clone (). text ();
const clientDataHash = await sha256 (clientData);
// authData + clientDataHash が署名と一致するか
const message = new Uint8Array ([ ... cbor.authData, ... clientDataHash]);
const signatureValid = await verifyEcdsaSignature (
publicKey,
message,
cbor.signature,
);
if (\ ! signatureValid) return false ;
// カウンターが単調増加していることを確認 — リプレイ攻撃対策
const lastCounter = parseInt (
( await env. ATTEST_KV . get ( `counter:${ keyId }` )) || '0' ,
10 ,
);
if (cbor.authData.counter <= lastCounter) return false ;
await env. ATTEST_KV . put (
`counter:${ keyId }` ,
cbor.authData.counter. toString (),
);
return true ;
} catch {
return false ;
}
}
このカウンター単調増加チェックが、多くのチームで省略されがちでありながらリプレイ攻撃対策としては最も効果的な部分です。Secure Enclave が署名するたびにカウンターが増えるため、サーバーは直近の値を覚えておくだけで済みます。
ノンス管理 — 一度きり・短命
ノンスは「本人証明」と「今まさに動いている証明」をつなぐ接着剤です。雑に扱うと最弱リンクになります。iOS/Android 両対応で次のようなパターンがおすすめです。
// workers/src/nonce.ts
export async function issueNonce ( env : Env ) : Promise < string > {
// 暗号学的に強い32バイトをhex化
const bytes = crypto. getRandomValues ( new Uint8Array ( 32 ));
const nonce = Array. from (bytes)
. map (( b ) => b. toString ( 16 ). padStart ( 2 , '0' ))
. join ( '' );
// 30秒TTLで保留中として保存
await env. NONCE_KV . put ( `nonce:pending:${ nonce }` , '1' , {
expirationTtl: 30 ,
});
return nonce;
}
export async function consumeNonce ( nonce : string , env : Env ) : Promise < boolean > {
// get-then-delete で原子的に消費
const pending = await env. NONCE_KV . get ( `nonce:pending:${ nonce }` );
if (\ ! pending) return false ;
await env. NONCE_KV . delete ( `nonce:pending:${ nonce }` );
// 使用済みマーカーで二重消費を防ぐ
await env. NONCE_KV . put ( `nonce:used:${ nonce }` , '1' , {
expirationTtl: 120 ,
});
return true ;
}
期待する挙動 : 正規クライアントはアテステーション直前に /nonce を呼び、30秒以内に使い切ります。サーバーは再利用を常に拒否します。nonce_mismatch のスパイクはほぼリプレイ攻撃の兆候なのでログで監視します。
観測可能性 — 何を記録し、何をグラフ化するか
測れないものは改善できません。私の Workers は検証ごとに次の形式で小さなイベントを吐いています。
interface AttestEvent {
timestamp : number ;
platform : 'ios' | 'android' ;
outcome : 'success' | 'failure' ;
reason ?: string ; // cert_chain_invalid 等
endpoint : string ; // 呼ばれた API
keyId : string ; // フル ID ではなく SHA-256 プレフィックスのみ
country ?: string ; // Cloudflare cf-ipcountry から
}
日常的に見るべきグラフは3つだけです。失敗理由別のレート(nonce_mismatch の急増はリプレイキャンペーンの兆候)、国別の失敗率(単一国が失敗の80%を占めたらスクリプト攻撃の発生源)、そして端末インテグリティの分布(MEETS_BASIC_INTEGRITY が増えたらルート化端末のトラフィックが増えている兆候)です。
Rork 特有の実装ポイント
Rork は React Native + Expo プロジェクトを生成するため、Expo のマネージドワークフローでは任意のネイティブモジュールを追加できない制約があります。現実的には以下2つのどちらかを採用します。
expo-dev-client による development build : ネイティブモジュールを載せつつ Expo の便利さを維持。App Attest と Play Integrity はどちらもネイティブアクセスが必須なので、私はこちらを本番でも使っています
Expo Modules API : App Attest / Play Integrity をラップするカスタムモジュールを書く方法。最初の手間は増えますが、Expo SDK のアップデート時に綺麗に追従できます
避けるべき実装は、JS ランタイムから fetch だけでアテステーションを完結させようとするパターンです。署名は Secure Enclave (iOS) または Keystore (Android) 内で行われなければならず、これはネイティブコードからしか呼び出せません。「バンドル ID をサーバーに POST するだけ」のような解説記事は何も証明できないので注意してください。
よくある落とし穴3つ — 私がハマった実例
シミュレーター / エミュレーターで検証が必ず失敗する : App Attest は Secure Enclave、Play Integrity は Google Play Services が必要です。開発環境では「アテステーションをスキップする開発者モード」を用意し、明示的なフラグでのみ有効化するのが正解です。私は最初これを忘れて、TestFlight ビルドが全滅しました
nonce を使い回してリプレイ攻撃される : チャレンジ用の nonce は必ずサーバー発行・一度きりの使用・30秒以内の有効期限にします。KV に used:nonce:{id} を入れて、二度目以降は弾く実装が必須です
Play Integrity の復号鍵を誤って Git に入れる : Play Console で発行される復号鍵は非常に秘匿性が高く、漏洩すると Play Integrity の信頼性そのものが崩れます。必ず wrangler secret 経由で管理し、.env ファイルには絶対に置かないでください。Rorkアプリのゼロトラストセキュリティ設計 にこのあたりの運用原則をまとめています
導入を渋らない方が良い3つの理由
ここまで読むと実装コストが気になるかもしれません。しかし、私の経験では導入する価値は以下の3点で十分に元が取れます。
不正リクエストが減ることで AI コストが下がる : 私のアプリでは月額 AI コストが約30%削減されました。これは単純に正規リクエストだけを処理するようになった効果です
App Store / Play ストアの信頼スコアが上がる : Apple も Google も、アテステーション採用アプリを内部的に優遇している兆候があります。少なくともリジェクト率は下がります
サポート工数が激減する : 「課金したのに反映されない」という問い合わせの多くが、実は偽装レシートを使ったユーザーからのものでした。検証を入れた後は本物の不具合だけが残るので、対応が楽になります
検証コストが気になる場合は、AI コーディング支援を活用して実装を高速化する選択肢もあります。アプリ開発者のためのAIコーディング支援ツール比較2026 で扱っているように、こうしたセキュリティ系コードは Claude Code の得意分野で、テストまで含めて1日で実装できました。
全体を振り返って — 明日やるべき最初の一歩
長い記事になりましたが、明日からの一歩としておすすめしたいのは 「App Attest / Play Integrity の鍵生成だけをまずリリースする」 ことです。検証ロジックをいきなり厳格化するとサポートが増えるため、最初の1〜2週間は「検証結果をログに残すだけ」で運用し、正規ユーザーの成功率が99%を超えることを確認してから本格的なブロックに切り替えます。
この段階的なロールアウトさえ踏めば、週末の数時間で導入でき、月々の損失が確実に減るセキュリティ対策として十分に機能します。Rorkで作ったアプリが売れ始めたら、次の週末にでもぜひ検討してみてください。