Skip to content
Back to Blog
·5 min read

Verifying StoreKit 2 transactions

VerificationResult, current entitlements, the Transaction.updates listener, finish(), appAccountToken, revocation and environment, and checking the signed JWS on your server.

StoreKit 2 hands you every transaction wrapped in a VerificationResult. Unlock content only from .verified, never from .unverified; start a Transaction.updates loop when the app launches so purchases made elsewhere are not missed; read Transaction.currentEntitlements to learn what the customer owns now; and call finish() only after you delivered what they paid for. Apple says StoreKit checks the values it gives you, and that for the most control you can verify the signed data yourself, on the device or on your server, using the JWS string in jwsRepresentation. On a server, Apple points to its App Store Server Library, which checks the signature, the certificate chain, the bundle ID and the environment for you. Before you act on a transaction, also look at its revocationDate, its appAccountToken and its environment.

This page is the verification side. For refunds that arrive as server notifications, read App Store refunds and consumption requests. To look at a signed string while debugging, paste it into the App Store JWS decoder, and to test purchases locally, open your .storekit file in the StoreKit configuration viewer.

On the device: VerificationResult

VerificationResult<SignedType> is a frozen enum with two cases:

  • .verified(SignedType): the value passed StoreKit's checks. The associated value is the App Store-signed value.
  • .unverified(SignedType, VerificationError): the value failed those checks. You still get the signed value, plus the reason.

Apple's VerificationError cases are invalidCertificateChain (a certificate in the chain is expired or untrusted), invalidDeviceVerification (the signed value was not generated for this device), invalidEncoding, invalidSignature (the signature does not match the header and payload), missingRequiredProperties and revokedCertificate (Apple notes this can sometimes resolve if you try again later).

What to do with .unverified is your call; Apple's samples say to "handle unverified transactions based on your business model". For a paid unlock, the safe choice is to grant nothing and log the error. The payloadValue property throws for unverified values, while unsafePayloadValue returns the data whatever the result; Apple says not to trust that one and to use it for debugging.

The three places transactions come from

  1. The purchase call. product.purchase(options:) returns a PurchaseResult: .success with a VerificationResult<Transaction>, .pending (the customer must act, for example Ask to Buy; if it completes it arrives through Transaction.updates) or .userCancelled.
  2. Transaction.currentEntitlements, a sequence of the latest transaction for each product the customer is entitled to. Apple lists: each non-consumable in-app purchase; the latest transaction for each subscription that renews whose renewal state is subscribed or inGracePeriod; and the latest for each non-renewing subscription, including finished ones. Refunded or revoked products do not appear, and consumables never do (use unfinished or all for those).
  3. Transaction.updates, for anything that happens outside the purchase call: Ask to Buy approvals, offer code redemptions, purchases made in the App Store, and purchases made on another device. Apple says to create a Task that iterates it as soon as the app launches, because unfinished transactions are delivered once, right after launch, and "without the Task to listen for these transactions, your app may miss them."

finish()

finish() tells the App Store you delivered the content or enabled the service. Apple's wording: call it after you deliver the purchased content or enable the purchased service; for on-demand resources, wait until the download is complete. A transaction stays in Transaction.unfinished until you call it. Call finish() after your own state is updated, not before, so a crash in between does not lose a purchase the customer paid for.

Putting it together

This compiles against the iOS 18 SDK with Swift 6.4 (also in Swift 6 language mode). The @MainActor class starts the listener in init, buys with an appAccountToken, rebuilds the unlocked set from entitlements, and finishes only verified transactions:

import Foundation
import StoreKit

@MainActor
final class Store {
    private(set) var unlocked: Set<String> = []
    private var updatesTask: Task<Void, Never>?

    // Start this as soon as the app launches (for example in init of your app model).
    init() {
        updatesTask = Task { [weak self] in
            for await result in Transaction.updates {
                await self?.handle(result)
            }
        }
    }

    deinit { updatesTask?.cancel() }

    func buy(_ product: Product, accountToken: UUID) async throws {
        let result = try await product.purchase(options: [.appAccountToken(accountToken)])
        switch result {
        case .success(let verification):
            await handle(verification)
        case .pending, .userCancelled:
            break
        @unknown default:
            break
        }
    }

    func refreshEntitlements() async {
        var ids: Set<String> = []
        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result, transaction.revocationDate == nil {
                ids.insert(transaction.productID)
            }
        }
        unlocked = ids
    }

    private func handle(_ result: VerificationResult<Transaction>) async {
        switch result {
        case .unverified(_, let error):
            // Do not unlock. Log the reason.
            print("unverified:", error)
        case .verified(let transaction):
            if transaction.revocationDate != nil {
                unlocked.remove(transaction.productID)
            } else {
                unlocked.insert(transaction.productID)
            }
            // Only after the content is delivered:
            await transaction.finish()
        }
    }
}

We type-checked this and did not run it, because purchases need a StoreKit configuration or a sandbox account. Apple's reference for purchase(options:) recommends PurchaseAction in SwiftUI and purchase(confirmIn:options:) in UIKit and AppKit apps; use those in your UI and keep the handling above.

What to check on each transaction

FieldWhat Apple saysWhat to do
revocationDateThe date the App Store refunded the transaction or revoked it from Family Sharing.If it is set, take access away. currentEntitlements already leaves these out.
appAccountTokenA UUID you set in the purchase options; the App Store returns the same value in the transaction. It is omitted if you did not provide one.Compare it to the signed-in user on your server, so a transaction cannot be attached to someone else's account.
environmentThe server environment that generated and signed the transaction: .production, .sandbox or .xcode on the device; Production or Sandbox in server data.Make your server reject anything that does not match the environment it expects.
expirationDate and isUpgradedThe date a subscription expires or renews; isUpgraded is true when the customer moved to another subscription.Do not grant access from an expired or upgraded transaction; Apple's own example skips both.
productIDYour product identifier from App Store Connect.Match it to what you unlock.

If you need to set or change an app account token for a purchase made outside your app, Apple's server API has a Set App Account Token endpoint.

On your server: check the JWS yourself

The signed string is a JWS in Compact Serialization: three Base64URL parts, header, payload and signature, joined by dots. It is the same format as JWSTransaction in the App Store Server API and in App Store Server Notifications V2, where it arrives as signedTransactionInfo (and signedRenewalInfo for renewal data) inside the notification's data. The on-device payload also has deviceVerification and deviceVerificationNonce fields for checking that the value belongs to the device.

Apple's description of the header: alg names the signing algorithm and x5c carries the certificate chain, in this order: the certificate holding the key that signed the JWS, an Apple intermediate certificate (with the Worldwide Developer Relations extension ID 1.2.840.113635.100.6.2.1), and an Apple root certificate. The root you trust must be one you downloaded from Apple, not the one inside the message: Apple's PKI site lists Apple Root CA - G3, and our decoder checks the chain against it. Our decoder also requires alg to be ES256.

You can write that check yourself, but Apple recommends its open source App Store Server Library, which has Swift, Java, Python and Node versions and the functions verifyAndDecodeTransaction, verifyAndDecodeAppTransaction and verifyAndDecodeRenewalInfo. In the Swift version, SignedDataVerifier takes the Apple root certificates, your bundle ID, your numeric app ID (required in production) and the environment, and it rejects a transaction whose bundle ID or environment differs. Its enableOnlineChecks option turns on revocation checking and checks expiry against the current date.

This builds and runs against App Store Server Library 6.0.0 (it rejects a garbage string, which is all we can test without a real signed transaction):

import Foundation
import AppStoreServerLibrary

func verifiedTransaction(
    signedTransaction: String,
    appleRootCertificates: [Data],
    bundleId: String,
    appAppleId: Int64?,
    environment: AppStoreEnvironment
) async throws -> JWSTransactionDecodedPayload? {
    let verifier = try SignedDataVerifier(
        rootCertificates: appleRootCertificates,
        bundleId: bundleId,
        appAppleId: appAppleId,
        environment: environment,
        enableOnlineChecks: true
    )
    switch await verifier.verifyAndDecodeTransaction(signedTransaction: signedTransaction) {
    case .valid(let payload):
        return payload
    case .invalid:
        return nil
    }
}

func shouldGrantAccess(_ t: JWSTransactionDecodedPayload, expectedUser: UUID) -> Bool {
    guard t.appAccountToken == expectedUser else { return false }
    return t.revocationDate == nil
}

On the device, send verification.jwsRepresentation to your server together with the user's session. On the server, verify, compare appAccountToken with the user, and then act. For long-running state such as renewals and refunds, rely on notifications rather than only on what the app reports.

Sources

All read on 1 November 2026.

Apple's pages and APIs change; check the pages above for current details.

Written by

Building local-first Mac & iOS productivity apps at Obelisk Club. About the author →