# Server-side receipt validation with App Store Server API v2 or Google RTDN

- **Pattern:** `ab-000404` (`app-store-iap-subscriptions.iap-integration.receipt-validation`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000404
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000404)

## 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:

```tsx
// 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`):

```ts
// 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 `/verifyReceipt` endpoint in backend code (Next.js API routes in `pages/api/` or `app/api/`, Express routes, Supabase Edge Functions in `supabase/functions/`, Cloud Functions). (2) Google's `androidpublisher.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 that `customerInfo.entitlements.active` is 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 for `JWSTransaction` parsed in the app, or `receiptData` sent 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/notifications` or 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.
  1. The easiest fix is to use RevenueCat (free tier available):
     ```tsx
     // After purchase, check server-validated entitlements
     const customerInfo = await Purchases.getCustomerInfo();
     const isPremium = customerInfo.entitlements.active['premium'] !== undefined;
     ```
  2. For a custom backend using Apple's App Store Server API v2 (JWT-based, recommended over legacy `/verifyReceipt`):
     ```ts
     // Server: POST /api/validate-purchase
     const client = new AppStoreServerAPIClient(
       privateKey, keyId, issuerId, bundleId,
       Environment.PRODUCTION
     );
     const response = await client.getTransactionInfo(transactionId);
     ```
  3. Store validated entitlement state in your database keyed to the user ID — never to the device alone
  4. Apple deprecated the `/verifyReceipt` endpoint — migrate to App Store Server API v2 if using the legacy endpoint

## External references

- cwe CWE-345
- owasp A07
- external apple-app-store-server-api-v2 — https://developer.apple.com/documentation/appstoreserverapi

Taxons: access-control

HTML version: https://auditbuffet.com/patterns/ab-000404
