- JWS signature verified with the leaf certificate from the
x5cheader - Full certificate chain verified up to Apple Root CA G3 (as of
@onesub/server@0.6.0) usingnode:crypto.X509Certificate— each cert in the chain must be signed by the next, be within its validity window, and the final cert must be issued by a bundled Apple root. Leaf-only verification was insufficient because a self-signed cert could mint a passing signature - Sandbox receipts rejected in
NODE_ENV=productionunlessONESUB_ALLOW_SANDBOX=trueis set (for TestFlight / pre-launch QA) - 72-hour receipt age limit enforced
- Apple webhooks accept only
signedPayloadJWS format. Pre-decoded payloads are rejected
- OAuth2 service account JWT assertion for Play Developer API v3
- Token caching with thundering-herd protection (promise deduplication)
- Webhook JWT verification via Google's JWKS when
pushAudienceis configured
- All
/onesub/validateinputs validated via zod schema receipt: max 10,000 charsuserId: max 256 charsproductId: max 256 charsplatform: enum['apple', 'google']- Request body size limited to 50KB (
express.json({ limit: '50kb' }))
- Apple: Only JWS-signed
signedPayloadaccepted. Signature verified via Apple JWKS - Google: When
pushAudienceis configured,Authorization: BearerJWT is verified against Google JWKS with audience claim check
- Currently open by design (consumer adds their own auth middleware)
- Recommended: Add auth middleware when mounting:
app.use('/onesub', yourAuthMiddleware, createOneSubMiddleware(config));
As of @onesub/server@0.5.0, POST /onesub/purchase/validate enforces a per-transactionId owner:
- Same
userId+ sametransactionId→ idempotent - Different
userId+ consumable →409 TRANSACTION_BELONGS_TO_OTHER_USER - Different
userId+ non-consumable → auto-reassigned to the newuserId(as of0.6.1) because a JWS verified against Apple Root CA proves the caller owns the original Apple account
Before 0.5.0, savePurchase silently no-op'd on duplicate transactionId, letting one receipt pass validation under arbitrary userIds.
Legitimate account/device migrations should go through POST /onesub/purchase/admin/transfer (requires config.adminSecret + X-Admin-Secret header).
Mounted only when config.adminSecret is set. Every request requires a matching X-Admin-Secret header (401 otherwise). These routes bypass receipt verification — treat the secret like a database password.
- userId is client-provided: The
validateendpoint trusts theuserIdfrom the request body. In production, extractuserIdfrom your auth token instead of trusting client input - Single subscription per user: Store returns only the most recent subscription per userId
- InMemoryStore: For development only. No eviction policy — memory grows unbounded. Use PostgresSubscriptionStore for production
Do not open a public issue. Report via GitHub Security Advisories so a fix can ship before the details are public.
Please include:
- Affected package(s) and version(s)
- Minimal reproduction (redact any real
JWS/purchaseToken/sharedSecret) - Suggested severity (low / medium / high / critical) and your reasoning