In StoreKit 2 every transaction you read comes wrapped in a VerificationResult, and you should only act on its .verified case. StoreKit checks Apple's cryptographic signature on each transaction on-device before handing it to you, so .verified means the transaction is authentic and untampered, and .unverified means something is wrong and you should not grant entitlement based on it. This is the modern replacement for the manual receipt validation StoreKit 1 forced on you, and it is why the code in every StoreKit 2 tutorial starts with case .verified(let transaction).
This post explains what VerificationResult actually is, the pattern for handling it in entitlement checks and purchases, and the question that trips up indie developers: whether on-device verification is enough or you need a server.
On this page
- What VerificationResult is
- Trust only the verified case
- Verifying a purchase result
- Is on-device verification enough?
- Server-side verification and RevenueCat
- Common gotchas
- FAQ
What VerificationResult is
StoreKit 2 delivers every transaction and its renewal info as a signed payload (a JSON Web Signature, or JWS) that Apple has cryptographically signed. When you read Transaction.currentEntitlements, Transaction.updates, Transaction.latest(for:), or the result of a purchase, StoreKit verifies that signature against Apple's certificate chain on-device and gives you a VerificationResult describing the outcome.
VerificationResult is an enum with two cases:
.verified(let safe)means StoreKit checked the signature and it is genuine and unmodified. The associated value is the trustworthyTransaction(orRenewalInfo, orAppTransaction)..unverified(let unsafe, let error)means the signature check failed. You get the payload anyway, plus aVerificationErrorexplaining why, but you must not treat it as authoritative.
The important shift from StoreKit 1: you no longer fetch and parse a receipt blob or call a validation endpoint just to know what a customer owns. StoreKit does the signature verification for you and the result type makes the trust boundary explicit in the type system. For where this sits in the bigger flow, see how an iOS subscription works end to end.
Trust only the verified case
The rule is simple: unlock features from .verified transactions only, and log or ignore .unverified ones. In an entitlement scan that looks like this:
import StoreKit
func refreshEntitlements() async {
var owned: Set<String> = []
for await result in Transaction.currentEntitlements {
switch result {
case .verified(let transaction):
// StoreKit verified Apple's signature on-device. Safe to trust.
owned.insert(transaction.productID)
case .unverified(let transaction, let error):
// Signature check failed. Do not grant access from this.
print("Unverified \(transaction.productID): \(error)")
}
}
applyAccess(owned)
}A small helper keeps the unwrapping out of your business logic when you just want the transaction or an error:
func checkedValue<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let safe):
return safe
case .unverified(_, let error):
throw error
}
}Verifying a purchase result
A purchase returns the same wrapper nested inside its result, so you verify before finishing the transaction. Finishing an unverified transaction, or granting access from it, is the mistake to avoid:
let result = try await product.purchase()
switch result {
case .success(let verification):
guard case .verified(let transaction) = verification else {
// Signature failed: do not unlock, do not finish.
return
}
unlock(transaction.productID)
await transaction.finish() // finish only verified transactions
case .userCancelled, .pending:
break
@unknown default:
break
}The pattern is identical everywhere StoreKit hands you a VerificationResult: pattern-match to .verified, act on the safe value, and treat .unverified as "do nothing."
Is on-device verification enough?
For many indie apps, yes. StoreKit's on-device signature check confirms the transaction genuinely came from Apple and was not altered, which defeats casual tampering. If your app is client-only and the cost of an occasional bypass is low, trusting .verified locally is a reasonable baseline.
It is not bulletproof. On-device checks can, in principle, be defeated on a compromised device, because the code deciding what to do with .verified is running on hardware the attacker controls. If your entitlement unlocks something expensive to give away (server-side compute, downloadable premium content, anything you pay real money to deliver), you want the entitlement decision made somewhere the customer cannot tamper with it: your server. Think of on-device .verified as the floor, not the ceiling.
Server-side verification and RevenueCat
To verify server-side, you have two realistic paths:
- Do it yourself with Apple's App Store Server API and App Store Server Library. Your app sends the signed transaction (or a transaction ID) to your backend, which validates the JWS against Apple's root certificates and checks current status through the API. This is the robust option, and also the most work: you are running and maintaining a subscription backend.
- Let RevenueCat do it. RevenueCat validates transactions on its servers, so
customerInfo.entitlementsis already a server-verified source of truth, without you standing up any of that infrastructure. For most indie apps this is the pragmatic answer, and it is one of the main reasons to route subscriptions through it rather than hand-rolling validation, as covered in adding subscriptions with RevenueCat and weighed in StoreKit 2 vs RevenueCat.
Common gotchas
- Do not skip the check. Reaching for
try result.payloadValueor force-unwrapping to avoid theswitchthrows away the entire point of the wrapper. Handle both cases explicitly. - Do not finish unverified transactions. Only call
finish()on a verified transaction you have acted on, or you can mark an unresolved purchase as done. .unverifiedis not always an attack. A clock set wildly wrong, or an environment mismatch, can fail verification for a legitimate customer. Log theVerificationErrorso you can tell tampering from a benign cause.- On-device verified is not server-verified. Do not describe a client-only check as fraud-proof. If the stakes are high, verify on a server.
- Renewal info is wrapped too.
RenewalInfoandAppTransactioncome through the sameVerificationResult, so apply the same pattern to them, not just toTransaction.
FAQ
What is VerificationResult in StoreKit 2?
It is the enum StoreKit 2 wraps every transaction in, with .verified and .unverified cases. StoreKit checks Apple's cryptographic signature on the transaction on-device and reports the outcome, so you know whether the payload is authentic before you trust it.
Do I still need to validate receipts like in StoreKit 1?
Not on-device. StoreKit 2 verifies the signature for you and hands back a VerificationResult, replacing the manual receipt fetch-and-parse of StoreKit 1. You may still choose server-side validation for higher assurance, but the old on-device receipt dance is gone.
Is on-device transaction verification secure enough?
For low-stakes, client-only apps, usually yes: it confirms the transaction came from Apple untampered. For entitlements that are expensive to grant, verify server-side, because on-device checks run on hardware an attacker controls.
What should I do with an unverified transaction?
Do not grant access or finish it. Log the associated VerificationError so you can distinguish tampering from a benign cause like a wrong device clock, and treat the transaction as untrusted.
How do I verify transactions on a server?
Use Apple's App Store Server API and App Store Server Library to validate the signed transaction against Apple's certificates, or route subscriptions through RevenueCat, which performs server-side validation for you and exposes a verified entitlement status.
Does RevenueCat handle verification for me?
Yes. RevenueCat validates transactions on its servers, so its entitlement status is already server-verified and you do not need to build receipt or JWS validation yourself.
Getting verification right is one of the small correctness details that separates a subscription that just works from one that leaks access or rejects real customers, and it is easy to get subtly wrong by hand. Spaceport ships it done: the generated SwiftUI project uses RevenueCat, whose entitlement status is server-verified, over subscription products Spaceport creates and prices in App Store Connect through the API, with the paywall, purchase, and restore flow already wired to that verified status. You get correct verification without writing the JWS or receipt plumbing yourself. And when you are lining up a waitlist and launch-day audience for the app, our sister tool Lighthouse covers that side.
From an indie iOS dev, for indie iOS devs.