StoreKit 2 with no server: verifying purchases safely
For most of my career, "do in-app purchases properly" meant standing up a server to validate receipts against Apple. With StoreKit 2 that's no longer the default answer — and for a large share of apps it's the wrong one. Here's how I verify purchases on-device, safely, with no backend at all.
The old StoreKit gave you an opaque, base64 blob — the app receipt — and a strong recommendation to ship it to your server, which would POST it to Apple's verifyReceipt endpoint and parse whatever came back. It worked, but it meant every indie app that just wanted to unlock "Pro" was suddenly running infrastructure: an endpoint, a shared secret, retry logic, and a parser for a format that changed under you. A lot of that work existed purely to answer one question — is this person actually entitled to the thing they tapped?
StoreKit 2, introduced in iOS 15 and now the baseline for any app I start, answers that question on the device. Apple signs every transaction with a JWS payload your app can verify against Apple's own certificate chain, using nothing but the system framework. No round-trip, no shared secret, no parser. The result is that a meaningful number of apps — single-platform, content unlocked locally — genuinely do not need a server anymore.
Entitlements are the source of truth
The mental shift with StoreKit 2 is to stop thinking about receipts and start thinking about entitlements. You don't ask "what did they buy and when?" You ask "what are they entitled to right now?" and the framework hands you the current, deduplicated answer.
That answer lives in Transaction.currentEntitlements — an async sequence of every transaction that currently grants access. Expired subscriptions drop out automatically. Refunded purchases drop out. Consumables aren't there (they're spent the moment you finish them). What's left is exactly the set of product IDs the user should have unlocked. You iterate it, verify each one, and flip your local flags:
import StoreKit
@MainActor
final class Entitlements: ObservableObject {
@Published private(set) var unlocked: Set<String> = []
/// Recompute access from the framework's current entitlements.
func refresh() async {
var owned: Set<String> = []
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue // ignore anything Apple didn't sign
}
// A subscription cancelled mid-period still grants access
// until it expires; revoked (refunded) ones are excluded here.
if transaction.revocationDate == nil {
owned.insert(transaction.productID)
}
}
unlocked = owned
}
func isUnlocked(_ productID: String) -> Bool {
unlocked.contains(productID)
}
}
That's the whole entitlement check. No receipt, no network, no server. The framework has already reconciled the App Store account's purchase history and is giving you the distilled result.
What the signature actually guarantees
Every Transaction StoreKit hands you is wrapped in a VerificationResult, and that wrapper is the part people skim past. It's an enum with two cases — .verified(T) and .unverified(T, VerificationError) — and the choice you make there is the entire security story of an on-device setup.
When you read a transaction, Apple has signed it as a JWS (JSON Web Signature) using a key whose certificate chains up to Apple's root. StoreKit checks that signature for you on-device. A .verified result means the cryptographic check passed: this payload was issued by Apple, for this app's bundle ID, and hasn't been tampered with in transit or on disk. That's a strong guarantee, and it's the one that replaces the server round-trip.
What .verified does not prove is intent or business logic. It tells you the data is authentic; it doesn't tell you whether a subscription is still inside its paid period or whether you've already granted a consumable. You still read expirationDate, revocationDate, and your own records. JWS authenticates the message — it doesn't make the decision for you.
Treat .unverified as "not entitled," full stop — never unwrap the payload and grant access anyway. It means the signature didn't validate: a jailbroken device, a tampered transaction, or a clock so far off that the certificate looks invalid. The associated value exists for logging and graceful messaging, not as an escape hatch. The day you write if case .unverified(let t, _) = result { grant(t) } is the day your IAP is trivially bypassed.
Listen for updates, always
Reading currentEntitlements at launch covers the steady state, but purchases also change while your app is running or while it's closed. A subscription auto-renews. A parent approves an Ask-to-Buy request hours after the child tapped Buy. Apple processes a refund and revokes a transaction. Family Sharing grants a purchase someone else made.
None of those originate from your purchase() call, so you'd miss them if you only checked at the obvious moments. Transaction.updates is an async sequence that delivers exactly these out-of-band transactions. The rule I follow on every project: start listening before the first UI appears, and keep the task alive for the whole app lifetime.
@main
struct MyApp: App {
@StateObject private var store = Store()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(store)
.task { await store.start() } // begin listening at launch
}
}
}
actor TransactionObserver {
/// Long-lived task that catches renewals, refunds, and Ask-to-Buy
/// approvals that arrive outside of an explicit purchase() call.
func listenForUpdates(_ onChange: @escaping @Sendable () async -> Void) -> Task<Void, Never> {
Task.detached {
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
await onChange() // recompute entitlements
await transaction.finish() // tell Apple we handled it
}
}
}
}
Start that listener early, because a transaction delivered before you attach can be missed for that session. And note the finish() at the end — that's not optional bookkeeping.
Finishing transactions, and why it matters
Calling transaction.finish() tells the App Store you've delivered the goods. Until you do, StoreKit considers the transaction unresolved: it will keep redelivering it through Transaction.updates on every launch, and for consumables the user may not be able to buy the item again. I've debugged more than one "why does this keep popping up" report that traced back to a missing finish() on an early-return path.
The discipline is simple — only finish after you've persisted the unlock. For a non-consumable or subscription, where entitlement is recomputed from currentEntitlements anyway, you can finish as soon as you've recorded it. For a consumable, finish only after the coins are credited and saved, so a crash mid-purchase still lets the transaction redeliver and complete.
Fetching products, buying, restoring
The rest of the surface is small and pleasantly modern. You fetch products by ID with Product.products(for:), kick off a purchase with product.purchase(), and handle the result. There is no separate "restore purchases" API in the old sense — currentEntitlements is the restore, because it reflects whatever the signed-in App Store account owns. A "Restore Purchases" button (which Apple still requires you to show) just calls your refresh, optionally after AppStore.sync() to force a fresh pull.
func buy(_ product: Product) async throws -> Bool {
let result = try await product.purchase()
switch result {
case .success(let verification):
guard case .verified(let transaction) = verification else {
return false // signature failed — don't unlock
}
await entitlements.refresh() // recompute from the source of truth
await transaction.finish() // then mark it handled
return true
case .userCancelled:
return false
case .pending:
return false // Ask-to-Buy: updates will deliver it
@unknown default:
return false
}
}
// "Restore Purchases" — required in the UI, trivial in StoreKit 2.
func restore() async {
try? await AppStore.sync() // force a refresh from the account
await entitlements.refresh()
}
Notice the .pending case carries no transaction. That's Ask-to-Buy waiting on a parent, or a payment method needing action. You don't unlock anything — you show a "waiting for approval" state and let your Transaction.updates listener deliver the verified transaction whenever approval lands.
What happens offline
This is the question that makes people nervous about dropping the server, and the answer is reassuring: Transaction.currentEntitlements reads from a signed cache the system maintains on-device, so it works offline. A subscriber who boards a plane keeps their Pro features. The JWS signature was verified when the transaction was written; re-reading it doesn't need the network.
The honest caveat is that the cache can be slightly stale. If a subscription expires mid-flight, the device may still report it as active until it next reconciles with the App Store. In practice this window is small and, for most apps, harmless — granting a few extra hours of access beats locking out a paying customer because their train went through a tunnel. The principle I hold to is rely on the last known verified state, and fail open for legitimate purchasers, not closed. If your content is so valuable that a stale hour is a real loss, that's a signal you might be in server territory — which is the next section.
When you genuinely still need a server
On-device verification is right for a great many apps, but I'd be selling you something if I claimed it fits all of them. There are concrete cases where a backend earns its keep:
- Cross-platform entitlements. If someone subscribes on iOS and expects access on your web app or an Android build, the App Store account can't tell those other platforms anything. You need your own user accounts and a server that records the entitlement against them.
- App Store Server Notifications. For reliable, near-real-time signals about renewals, billing retries, refunds, and grace periods — independent of whether the app is even installed — you want Apple posting v2 notifications to your endpoint. That's the backbone of dunning emails and churn analytics.
- Fraud-sensitive or high-value content. If unlocking something has real marginal cost to you — a paid API behind it, expensive media, anything where a bypass actually hurts — you want server-side validation with the App Store Server API as the gate, not just an on-device flag.
The pattern in all three is the same: the value of the purchase doesn't live entirely on one device. When entitlement has to be authoritative across devices, platforms, or your own infrastructure, a server stops being overhead and becomes the actual source of truth. StoreKit 2 even helps here — the same JWS transactions verify server-side, so you're not back to the old verifyReceipt dance.
For everything else — the single-platform app that unlocks features locally, which is most of what I ship — resist the reflex to build a backend out of habit. A server you don't need is a server you have to keep alive, secure, and pay for, and every one of those is a way to break a purchase flow that StoreKit was perfectly capable of handling on its own. Read entitlements, verify the signature, listen for updates, finish your transactions. That's a complete, safe IAP implementation, and it runs entirely on the phone in the customer's hand.