Server-side receipt validation with App Store Server API v2 or Google RTDN
Why it matters
Client-side receipt validation is trivially bypassed: any user with a proxy tool (Charles Proxy, mitmproxy) can intercept the platform's receipt response and return a forged success. This is OWASP A07 (Identification & Authentication Failures) applied to entitlement decisions — the access control check runs in an environment the attacker fully controls. CWE-345 (Insufficient Verification of Data Authenticity) is the direct mapping. The business consequence is that paid features become free for any motivated user. Apple deprecated the legacy /verifyReceipt endpoint in favor of App Store Server API v2 (JWT-signed transactions, api.storekit.itunes.apple.com) — apps still using the legacy endpoint face a compounding validation gap as it is phased out.
Severity rationale
High because client-side receipt validation is bypassable by any user with a network proxy, allowing paid features to be unlocked without payment.
Remediation
Move receipt validation to a server you control, or delegate it to a third-party SDK backend (RevenueCat, Adapty, Qonversion). Never make entitlement decisions solely from a success callback in the app process.
The simplest path uses RevenueCat (free tier available) — customerInfo.entitlements.active is server-validated:
// After purchase — check server-validated entitlements, not the purchase callback alone
const customerInfo = await Purchases.getCustomerInfo();
const isPremium = customerInfo.entitlements.active['premium'] !== undefined;
For a custom backend using Apple's App Store Server API v2 (migrate away from the deprecated /verifyReceipt):
// POST /api/validate-purchase (server-side)
const client = new AppStoreServerAPIClient(
privateKey, keyId, issuerId, bundleId, Environment.PRODUCTION
);
const txInfo = await client.getTransactionInfo(transactionId);
Store validated entitlement state in your database keyed to the user ID (src/lib/entitlements.ts or equivalent) — never to the device alone.
Detection
- ID:
receipt-validation - Severity:
high - What to look for: Count all relevant instances and enumerate each. Determine where receipt/purchase validation occurs. The critical distinction is client-side vs. server-side. Client-side only validation means the app itself checks the receipt — trivially bypassed. Look for: (1) API calls to Apple's App Store Server API (
api.storekit.itunes.apple.com/inApps/v1/, the modern v2 endpoint) or the legacy/verifyReceiptendpoint in backend code (Next.js API routes inpages/api/orapp/api/, Express routes, Supabase Edge Functions insupabase/functions/, Cloud Functions). (2) Google'sandroidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/in backend code. (3) If using RevenueCat, Adapty, or Qonversion: these SDKs handle server-side validation for you — check thatcustomerInfo.entitlements.activeis used to gate premium features (this IS server-validated). (4) Client-only pattern (fail): receipt data decoded or verified entirely within the app without a backend call — look forJWSTransactionparsed in the app, orreceiptDatasent directly to a local validation function rather than a server. Also check for App Store Server Notifications v2 webhook handler in the backend —POST /api/v1/apple/notificationsor similar — which validates the signed payload from Apple's server. - Pass criteria: Receipt/entitlement validation occurs on a server (your backend or a third-party SDK's backend). At least 1 implementation must be verified. Client-side entitlement decisions are derived from server-validated data (CustomerInfo from RevenueCat, entitlement from Adapty/Qonversion, or a custom backend response).
- Fail criteria: Purchases are validated client-side only (receipts decoded/verified in the app with no backend call). Or: no validation at all — premium features unlocked solely based on the purchase success callback without any verification step.
- Skip (N/A) when: No IAP detected in the app.
- Detail on fail:
"src/lib/iap/validate.ts decodes the App Store receipt locally without calling a backend — client-side validation is trivially bypassed with a mock receipt"or"Premium features unlocked in PurchaseSuccess callback without any entitlement verification — receipt not validated at all" - Remediation: Client-side receipt validation is a critical business risk regardless of App Store policy — it allows any user to unlock paid features by intercepting or faking a receipt response.
- The easiest fix is to use RevenueCat (free tier available):
// After purchase, check server-validated entitlements const customerInfo = await Purchases.getCustomerInfo(); const isPremium = customerInfo.entitlements.active['premium'] !== undefined; - For a custom backend using Apple's App Store Server API v2 (JWT-based, recommended over legacy
/verifyReceipt):// Server: POST /api/validate-purchase const client = new AppStoreServerAPIClient( privateKey, keyId, issuerId, bundleId, Environment.PRODUCTION ); const response = await client.getTransactionInfo(transactionId); - Store validated entitlement state in your database keyed to the user ID — never to the device alone
- Apple deprecated the
/verifyReceiptendpoint — migrate to App Store Server API v2 if using the legacy endpoint
- The easiest fix is to use RevenueCat (free tier available):
External references
- cwe · CWE-345 — Insufficient Verification of Data Authenticity
- owasp:2021 · A07 — Identification and Authentication Failures
- external · apple-app-store-server-api-v2 — Apple App Store Server API v2 — Receipt Validation
Taxons
History
- 2026-04-18·v1.0.0·Initial import from app-store-iap-subscriptions·automated