How to Validate iOS and macOS In-App Purchases Using StoreKit 2 and Server-Side Swift
Back in December of 2023 my OpenAI wrapper Pico (available for iPhone, iPad, Mac and HomePod, download it on the App Store) got hacked badly.
What I needed was a reverse proxy server to securely store the OpenAI API key. Simple, right? As it turns out, it wasn’t.
This is the story of how it took me five months and many conversations with Apple engineers to find a working solution. I learned that the combination of StoreKit 2 and the App Store Server Library isn’t always straightforward, and there are many edge cases where things go wrong.
Note: I have open-sourced Pico Proxy. If you’re looking for a free OpenAI proxy server that can be deployed with a single click on Railway, check out and star https://github.com/PicoMLX/PicoAIProxy)
The Tools We’ll Use
- Swift client app for iOS or macOS using StoreKit 2
- A Swift server app using HummingBird (Vapor works as well)
- Apple’s App Store Server Library to validate purchases on the server. The library is a wrapper for the App Store Server API, but as we’ll see in a bit, the server doesn’t need to call the App Store Server API anymore when using StoreKit 2. This part might be a bit confusing.
What NOT to Do
It’s very simple, if you’re using StoreKit 2 in your app (and you should), you don’t want to touch the deprecated App Store receipts. Ever.
You will find many blog posts and Stack Overflow answers explaining how to load the app’s App Receipt from disk and submit it to a server for validation:
// Fetch App Store receipt from disk
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
let receiptData = try? Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) {
// Encode receipt as base64 so we can send it to the server
let body = receiptData.base64EncodedString(options: [])
// Call server with base64 encoded receipt as body
...
}
There are a couple of issues with this approach. One is that Xcode can create local receipts for debug builds (as opposed to regular receipts created by the App Store servers). The App Store Server Library will recognize and parse local receipts without throwing an error (which I personally think it should), but then will throw a 404 not found error when trying to fetch the transaction from the App Store server. To make matters worse, there is no easy way to see the difference between Xcode and regular App Store receipts. Hint: open the receipt in a hex editor, and if the receipt contains lots of zeros, it’s probably an Xcode receipt. And it doesn’t stop here: if you ever used local receipts for your app in the past, the local App Receipt might linger around and cause hard-to-debug issues.
However, the biggest issue with App Store receipts is when you use StoreKit 2 for in-app purchases. What you might see is that everything works for you and your TestFlight users. But when you submit the app, it gets rejected by Apple.
It turned out the issue they were seeing was: the app says the subscription was purchased, the proxy server received a valid App Store receipt, but the proxy server couldn’t extract a transaction from the receipt. This issue took me months to figure out. How is it possible that only the App Store review team has issues and the app works perfectly for me and every TestFlight user?
The answer became clear after adding new TestFlight users. Their purchases couldn’t initially be validated by the server either. But then magically, the next day, they were able to use the app.
It turns out that an on-device purchase using StoreKit 2 doesn’t automatically update the App Store receipt, even though the app receives a notification from the App Store servers after a successful purchase. A developer can force a download of the App Store receipt using SKReceiptRefreshRequest, but that method will always present a login dialog to the user. Not a great user experience.
Since you can’t expect a user to wait a few hours until the App Store receipt has been magically updated, there had to be another way. And it turns out there is.
Using StoreKit 2 to Validate an In-App Purchase
Apple cryptically describes the correct way to validate StoreKit 2 in-app purchases like this (source):
The
verifyReceipt
endpoint is deprecated. To validate receipts on your server, follow the steps in Validating receipts on the device on your server. To validate in-app purchases on your server without using receipts, call the App Store Server API to get Apple-signed transaction and subscription information for your customers, or verify theAppTransaction
andTransaction
signed data that your app obtains. You can also get the same signed transaction and subscription information from the App Store Server Notifications V2 endpoint.
I found this text extremely confusing. What does “to validate receipts on your server, follow the steps in Validating receipts on the device on your server” even mean? (The link refers to manually validating a receipt, which doesn’t make sense since the App Store Server Library takes care of that). Worse, the text suggests that you can either use receipts or Transactions. This isn’t correct. If you use StoreKit 2, you shouldn’t use receipts for reasons described above.
It turns out validating Transactions and AppTransactions is actually pretty straightforward. And even better: it’s much faster than validating receipts because your server can cryptographically verify the validity of a Transaction on the server, without calling the App Store server. The fact that the library is called App Store Server Library but the App Store server isn’t invoked by the library in this scenario is confusing in itself, but that’s how it works.
Step 1: get the Transaction
This is pretty straightforward. I copied the in-app purchase architecture from the WWDC example Backyard Birds. I also used StoreKit 2’s built-in SwiftUI views to show the purchase options to the user.
Pro-tip: Use a per-user unique appAccountToken at the time of the purchase to keep track of your users. This is the easiest way to add a rate limiter to the proxy server.
You need to parse the Transaction every time your app receives a Transaction from the App Store server. The parsed JWS representation of the transaction is then stored.
There are three situations where your app will receive a Transaction: after an in-app purchase, when StoreKit 2 receives a push notification from the App Store servers, and when entitlements get updated. Here’s how we can handle the three cases in Backyard Bird’s StoreSubscriptionController:
import StoreKit
@MainActor
public final class StoreSubscriptionController: ObservableObject {
@Published public private(set) var jwsTransaction: String?
public func purchase(option subscription: Subscription) async -> PurchaseFinishedAction {
let action: PurchaseFinishedAction
do {
// Add user identifier to transaction
let idUUID = UUID()
let result = try await subscription.product.purchase(options: [.appAccountToken(idUUID)])
switch result {
case .success(let verificationResult):
// Set the JWS token after purchase
jwsTransaction = verificationResult.jwsRepresentation
...
}
}
}
// Handle push notification from App Store
internal func handle(update status: Product.SubscriptionInfo.Status) {
guard case .verified(let transaction) = status.transaction,
case .verified(let renewalInfo) = status.renewalInfo else {
return
}
if status.state == .subscribed || status.state == .inGracePeriod {
jwsTransaction = status.transaction.jwsRepresentation
}
...
}
// Handle updated entitlement
func updateEntitlement(groupID: String) async {
guard let statuses = try? await Product.SubscriptionInfo.status(for: groupID) else {
return
}
for status in statuses {
guard case .verified(let transaction) = status.transaction,
case .verified(let renewalInfo) = status.renewalInfo else {
continue
}
if status.state == .subscribed || status.state == .inGracePeriod {
jwsTransaction = status.transaction.jwsRepresentation
}
...
}
}
Step 2: Send the Transaction to the Proxy Server
Next, we’ll send the transaction to our server, which in my case is Pico Proxy. Since Pico (the client app) uses CleverBird and Get, I’ll be using Get to send the Transaction and receive a session token from Pico Proxy.
class PicoClient {
var authToken: String?
func authenticate() async throws {
// Set body to `jwsTransaction` property of `StoreSubscriptionController`
guard let body = await StoreActor.shared.subscriptionController.jwsTransaction else {
// User has no subscription
throw YourClientError.noSubscription
}
let tokenRequest = Request<Token>(
path: "appstore",
method: .post,
body: body,
headers: nil)
let clientConfiguration = APIClient.Configuration(baseURL: "<Pico Proxy URL here>")
let client = APIClient(configuration: clientConfiguration)
let tokenResponse = try await client.send(tokenRequest)
self.authToken = tokenResponse.value.token
}
func chatConnection() -> OpenAIAPIConnection {
return OpenAIAPIConnection(apiKey: authToken ?? "NO_KEY",
organization: organization,
scheme: scheme.rawValue,
host: host,
chatCompletionPath: chatCompletionPath,
port: port)
}
...
}
Step 3: Validate the Transaction on the Server
There are a few steps required to validate a Transaction on the server using Apple’s App Store Server Library. Interestingly, the library will not invoke the App Store server to validate a transaction; instead, the transaction is cryptographically validated locally on the server.
Note: You can find the full code for validation for Pico Proxy in AppStoreAuthenticator.swift. Pico Proxy uses HummingBird a lightweight Server-side Swift platform, and can be easily substituted by Vapor if preferred.
Apple’s root certificates are required, which is explained in this WWDC video. If you’re using Pico Proxy, these certificates are already included in the repository.
The steps are as follows:
- Fetch the JWS representation of the transaction (Pico Proxy expects it in the body)
- Load the root certificates from disk (note that Linux and Mac handle files differently; see Pico Proxy source for details)
- Create a SignedDataVerifier
- Parse the transaction
- If the signatures are validated, check the payload to ensure the transaction hasn’t expired yet. We’re checking for expiresDate and revocationDate (in case of refunds and family removal)
private func validateJWS(jws: String, environment: Environment, request: HBRequest) async throws -> JWSTransactionDecodedPayload? {
// 1. Set up JWT verifier
let rootCertificates = try loadAppleRootCertificates(request: request)
let verifier = try SignedDataVerifier(rootCertificates: rootCertificates, bundleId: bundleId, appAppleId: appAppleId, environment: environment, enableOnlineChecks: true)
// 2. Parse JWS transaction
let verifyResponse = await verifier.verifyAndDecodeTransaction(signedTransaction: jws)
switch verifyResponse {
case .valid(let payload):
// Check expiry date
if let date = payload.expiresDate, date < Date() {
request.logger.error("Subscription of user \(payload.appAccountToken?.uuidString ?? "anon") expired on \(date)")
throw HBHTTPError(.unauthorized)
}
// Check revocation date (for refunds and family removal)
if let date = payload.revocationDate, date < Date() {
request.logger.error("Subscription of user \(payload.appAccountToken?.uuidString ?? "anon") was revoked on \(date)")
throw HBHTTPError(.unauthorized)
}
request.logger.info("AppStoreAuthenticator: validated tx for user \(payload.appAccountToken?.uuidString ?? "anon") in \(environment)")
return payload
case .invalid(let error):
switch error {
case .INVALID_JWT_FORMAT:
request.logger.error("validateJWS failed: invalid JWT format")
case .INVALID_CERTIFICATE:
request.logger.error("validateJWS failed: invalid certificate")
case .VERIFICATION_FAILURE:
request.logger.error("validateJWS failed: verification failed")
case .INVALID_APP_IDENTIFIER:
request.logger.error("validateJWS failed: invalid app identifier")
case .INVALID_ENVIRONMENT:
// Return nil so caller can try a different environment
return nil
}
throw HBHTTPError(.unauthorized)
}
}
And that’s how you verify StoreKit 2 purchases on a server. Happy coding!