Shipping a StoreKit 2 Subscription with Server-Side Verification

By Harjot Singh Panesar | September 1, 2026 | 6 min read

Shipping a StoreKit 2 Subscription with Server-Side Verification

I added a paid tier to AirWait, my flight companion app: a monthly and a yearly subscription, gated with StoreKit 2 on the device and verified by a Firebase Cloud Function on the server. It works, it survives cancellation and expiry correctly, and getting there cost me several hours on one problem that almost nothing warns you about.

This is the whole architecture, the decisions I would make again, and the trap in the middle.

First, decide what "paid" means

Before any code, the harder question: what do subscribers get?

The tempting answer is to cap something existing. Free users get three saved flights, paid users get unlimited. It converts, and I refused to do it. Every feature that shipped free stays free and uncapped. The paid tier only unlocks additive features.

The reason is not sentimentality, it is churn. Taking away something a user already relies on generates support email, one-star reviews explaining that the app used to work, and a specific kind of resentment that follows you around the App Store. Adding something new generates neither. The ceiling is lower and the floor is much higher.

The two-source truth problem

StoreKit 2 makes local entitlement checking genuinely easy. You iterate the current entitlements, and you know whether this user has an active subscription. It is instant, it works offline, and it requires no backend at all.

So why add a server? Because local-only gating means the device is the sole authority on who has paid. That is fine until you want to know anything on the server — send a subscriber-only notification, populate a subscriber-only data feed, or simply answer "how many active subscribers do I have" without waiting for each device to check in.

My gate ends up as a boolean OR:

isPro = localActive || serverActive

Local comes from StoreKit's current entitlements. Server comes from a flag on the user's Firestore document, maintained by a Cloud Function. Either one being true grants access.

The OR is deliberate and it is the right default. A user who has genuinely paid must never be locked out because a server was slow, a webhook was delayed, or their phone was in airplane mode. The failure mode of an OR is that access persists slightly too long. The failure mode of an AND is that a paying customer gets a paywall, which is the one outcome guaranteed to produce a refund request and a bad review.

How verification actually flows

When a purchase completes on the device, the app writes the signed transaction to Firestore. A Cloud Function triggers on that write, verifies the signature against Apple's root certificates using Apple's own server library, and — only if verification passes — writes the subscription state onto the user's document.

Two things matter here.

The client never writes its own entitlement. Firestore security rules make the subscription fields server-write-only and client-read-only. If the app could set its own isPro flag, the entire verification step would be theatre; anyone with a rooted device and the Firebase SDK could grant themselves a subscription.

Data flows through Firestore, not through a REST endpoint. There is no custom API to authenticate, rate-limit or keep online. The app writes to a collection it is allowed to write to, and a trigger does the privileged work.

Cancellation and expiry arrive separately, through App Store Server Notifications. Apple posts subscription lifecycle events to a webhook, which verifies them the same way and updates the user document. Without this, a cancelled subscriber keeps their server-side flag indefinitely, because nothing on the device is obliged to tell you they stopped paying.

The trap: why your server rejects every valid purchase

Here is the part that cost me hours, and the reason I wrote this post.

During development you almost certainly test purchases with a local StoreKit configuration file in Xcode. It is excellent: instant purchases, no App Store Connect round-trip, subscriptions that renew in minutes.

Transactions signed by that local configuration file will always fail server-side verification, and the error will not tell you why in any obvious way. In my case the library threw a verification exception complaining about the certificate chain length. The chain from a locally-signed transaction does not have the structure Apple's real chain has, because Xcode signed it, not Apple.

Worse, and this is the part that wastes the time: local StoreKit testing never contacts Apple at all, so you also receive zero server notifications. You are staring at an empty log wondering whether your webhook URL is wrong, your certificates are stale, or your function is even deployed. All three can be perfectly fine.

I went and re-verified my Apple root certificates, twice. They were never the problem.

Server-side verification can only be tested with:

  • A real Sandbox tester account from App Store Connect, signed in on the device.
  • A real device, not the simulator.
  • The Xcode scheme's StoreKit Configuration set to None. This is the setting people miss. Leave it pointing at your .storekit file and you are still testing locally no matter which account you are signed into.

Once those three line up, real Apple-signed transactions arrive, verification passes, and notifications start flowing. My advice: build against the local configuration file for UI and paywall work, then switch all three settings at once for a dedicated verification pass. Do not try to debug server verification and paywall layout in the same session.

Cancellation is not immediate, and that is correct

When a user cancels, Apple's model keeps the subscription active until the end of the paid period. They paid for the month; they get the month.

This regularly gets reported as a bug by whoever is testing your app, so it is worth stating clearly: a cancelled subscription showing as active is the expected behaviour. What you receive at cancellation time is a notification that auto-renewal is off. The expiry event arrives later, when the period actually ends.

The stale flag, and the fix

The OR gate has one genuine failure mode. If the local StoreKit entitlement is cached and stale, the app can show "Active" after the subscription has really expired.

Two changes close it:

  • Re-check entitlements when the app comes to the foreground, rather than only at launch. A subscription that expires while the app sits backgrounded for three days should not survive because nobody asked again.
  • When the server says no, re-verify locally rather than trusting the cache. If the Firestore listener reports that the subscription has lapsed, that is a signal to ask StoreKit again, not a signal to ignore because the local flag disagrees.

Note the asymmetry: the server saying "not subscribed" triggers a local re-check, it does not immediately revoke access. Access is only withdrawn when both sources agree.

What I would do the same way again

  • Additive-only paid features. No regrets, no angry reviews about removed functionality.
  • Firestore trigger instead of a REST endpoint. One less service to secure and keep alive.
  • Server-write-only entitlement fields. Non-negotiable. Verification without this is decorative.
  • OR-gating with local priority. Never paywall someone who has paid.
  • Handling the webhook from day one. It is tempting to defer, because purchases work without it. Then your first cancellation never propagates and you are debugging in production.

And one thing I would do differently: I would test server verification with a Sandbox account on day one, before building the paywall UI. The infrastructure was correct for hours while I looked for a fault in it, because my test setup could not possibly have exercised it.

Adding subscriptions to an iOS app?

I have shipped StoreKit 2 with server-side verification end to end, including the parts Apple's documentation leaves you to discover.

Talk to me about your project

Related Articles