Why Apple Sandbox Returns Fresh JWTs with Stale Subscription Dates

Getting Old Transactions on Test Flight Apple Account

Summary

In the Apple Sandbox environment, when a test user subscribes to an auto-renewable subscription, the received JWT transactionInfo contains stale dates — purchaseDate and expiresDate reflect a previous subscription period, even though the JWT itself was signed today. This creates a confusing scenario where the signature is valid and current, but the underlying transaction data is outdated.

Key Takeaway: Apple Sandbox reuses existing transaction records for test subscriptions, returning old transaction metadata signed with fresh certificates. This is a known Sandbox behavior, not a bug in your implementation.

Root Cause

The root cause lies in how Apple’s App Store Server handles transaction record persistence in the Sandbox environment:

  • Apple reuses originalTransactionId: When a test user subscribes again (even after expiration or cancellation), Apple does not create a brand new transaction record. Instead, it reuses the existing transaction tied to the originalTransactionId.
  • Sandbox transaction deduplication: Apple’s servers optimize for consistency with production behavior — in production, renewals and lapses maintain the same originalTransactionId.
  • Date fields are pulled from the existing record: The purchaseDate and expiresDate in the JWT payload come from the database record associated with that originalTransactionId, which may be months old.
  • Signature is regenerated for each request: The JWT is freshly signed using today’s certificate, but the payload data is pulled from the existing transaction record, not created anew.

Key Takeaway: The signature being “today” does not mean the transaction data is new — Apple signs whatever data exists in the transaction record at request time.

Why This Happens in Real Systems

This behavior manifests in real systems for several reasons:

  • Subscription continuity modeling: Apple treats all subscription events (initial purchase, renewal, lapse, re-subscribe) as updates to a single transaction chain, identified by originalTransactionId.
  • Sandbox data persistence: Unlike what developers expect, Sandbox environments retain transaction data indefinitely, unlike the sometimes-reset receipt data in older test approaches.
  • Server-to-server notification alignment: The same transaction record feeds both the /transactionHistory endpoint and server-to-server notifications, ensuring consistency — but this means old data surfaces when the same user re-subscribes.
  • No “fresh start” for test accounts: Apple does not provide a mechanism to create truly fresh subscription records for the same sandbox test user account.

Key Takeaway: This is architectural — Apple prioritizes transaction chain integrity over “fresh” test data, which aligns with production behavior but confuses testing workflows.

Real-World Impact

This behavior affects production systems in specific ways:

  • Subscription validation logic breaks: If your backend checks purchaseDate > some_recent_threshold to determine if a subscription is “new,” it will incorrectly reject valid re-subscriptions.
  • Grace period calculations fail: Using stale expiresDate for grace period logic can cause premature access revocation.
  • Analytics and attribution issues: Marketing teams cannot accurately attribute new subscriptions to campaigns when dates are misaligned.
  • Refund and dispute handling: Old transaction dates combined with new signatures can cause confusion in fraud detection systems.
  • Migration and audit failures: When migrating subscription data or auditing transaction logs, the mismatch between signedDate and transaction dates creates reconciliation problems.

Key Takeaway: Relying on purchaseDate for business logic without accounting for transaction reuse is a common source of subscription management bugs.

Example or Code (if necessary and relevant)

When decoding the JWT payload, you might see something like this:

{
  "signedDate": 1706745600000,
  "originalTransactionId": "2000000000000000",
  "transactionId": "2000000123456789",
  "productId": "premium.monthly",
  "purchaseDate": 1696118400000,
  "expiresDate": 1696204800000,
  "quantity": 1
}

Note: signedDate is “today” but purchaseDate and expiresDate are from months ago — this is the expected behavior in Sandbox, not an error.

How Senior Engineers Fix It

Senior engineers address this with robust validation strategies:

  • Use transactionId, not originalTransactionId, for new purchase detection: The transactionId changes for each purchase/renewal event, while originalTransactionId stays constant.
  • Implement signedDate-based freshness checks: Use the JWT’s signedDate field as the authority for “when this data was retrieved,” not purchaseDate.
  • Track subscription state changes event-driven: Rather than relying on date comparisons, track the actual state transitions (ACTIVE → EXPIRED → ACTIVE) via server-to-server notifications.
  • Add sandbox-specific test fallbacks: Implement test mode logic that acknowledges known Sandbox behaviors and adjusts validation accordingly.
  • Store transactionId chains: Maintain your own mapping of transactionId → status, updating on each new transactionId for the same originalTransactionId.

Key Takeaway: Never assume purchaseDate represents a new subscription — always use transactionId for freshness and signedDate for retrieval timing.

Why Juniors Miss It

Junior engineers commonly overlook this issue because:

  • Documentation assumes production patterns: Apple’s documentation focuses on production flows where transaction reuse is expected and correct; Sandbox differences are not explicitly highlighted.
  • Intuitive expectations vs. reality: Developers expect “new subscription = new dates,” which is logically sound but architecturally incorrect in Apple’s system.
  • Testing only happy paths: Initial sandbox tests often use brand-new test users, masking the behavior until existing test accounts are reused.
  • JWT validation focus: Most debugging focuses on signature verification, which passes — leading to false confidence that the data is correct.
  • Lack of domain knowledge: Understanding subscription transaction chains, originalTransactionId semantics, and receipt lifecycle requires experience with Apple’s specific implementation details.
  • No visible error: Unlike a 400/500 HTTP error, this returns 200 with “wrong” data, making it appear functional until business logic fails.

Key Takeaway: This is a subtle semantic issue, not a visible error — it passes all validation checks while delivering incorrect data for naive business logic.

Leave a Comment