How is a StoreKit JWS built, and what must a server check before trusting signedTransactionInfo?

28.4.1 · L5 · Interview

Predict first

Which tampered tokens does the verifier accept?

import Foundation
import CryptoKit

func b64url(_ d: Data) -> String {
    d.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
}
func unb64url(_ s: String) -> Data? {
    var t = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
    while t.count % 4 != 0 { t += "=" }
    return Data(base64Encoded: t)
}
func json(_ o: [String: Any]) -> Data { try! JSONSerialization.data(withJSONObject: o, options: [.sortedKeys]) }

let key = P256.Signing.PrivateKey()
let header = json(["alg": "ES256", "x5c": ["<leaf>", "<intermediate>", "<root>"]])
let payload = json(["bundleId": "com.example.app", "productId": "pro.annual", "transactionId": "2000000123456789"])
let signingInput = b64url(header) + "." + b64url(payload)
let signature = try! key.signature(for: Data(signingInput.utf8)).rawRepresentation
let jws = signingInput + "." + b64url(signature)
print("parts:", jws.split(separator: ".").count, "| raw signature bytes:", signature.count, "| has padding:", jws.contains("="))

func verify(_ token: String, with pub: P256.Signing.PublicKey) -> String {
    let p = token.split(separator: ".", omittingEmptySubsequences: false).map(String.init)
    guard p.count == 3, let h = unb64url(p[0]),
          let hj = try? JSONSerialization.jsonObject(with: h) as? [String: Any] else { return "malformed" }
    guard hj["alg"] as? String == "ES256" else { return "rejected: alg \(hj["alg"] ?? "missing")" }
    guard let raw = unb64url(p[2]), let sig = try? P256.Signing.ECDSASignature(rawRepresentation: raw) else { return "bad signature encoding" }
    return pub.isValidSignature(sig, for: Data((p[0] + "." + p[1]).utf8)) ? "valid" : "invalid signature"
}
print("original:", verify(jws, with: key.publicKey))
let parts = jws.split(separator: ".").map(String.init)
let forgedPayload = b64url(json(["bundleId": "com.example.app", "productId": "pro.lifetime", "transactionId": "2000000123456789"]))
print("payload swapped:", verify(parts[0] + "." + forgedPayload + "." + parts[2], with: key.publicKey))
let noneHeader = b64url(json(["alg": "none"]))
print("alg none:", verify(noneHeader + "." + parts[1] + ".", with: key.publicKey))
print("other key:", verify(jws, with: P256.Signing.PrivateKey().publicKey))
print("decoded payload:", String(data: unb64url(parts[1])!, encoding: .utf8)!)
  • the original is valid, a swapped payload is an invalid signature, alg none is rejected, another key is invalid
  • a swapped payload still verifies because the header is unchanged
  • alg none is accepted when the signature is empty

Mental model. A notarised letter: the stamp (signature) covers the exact text, the notary's credentials (certificate chain) must trace to an authority you trust, and a letter addressed to someone else is genuine but not for you.

A JWS (the compact serialisation Apple uses) is base64url(header).base64url(payload).base64url(signature). The header contains alg: "ES256" and an x5c array of base64 DER certificates (leaf first); the payload is JSON (bundle id, product id, transaction id, dates, environment); the signature is ECDSA P-256 over the ASCII string header.payload. The check builds one with CryptoKit and shows the details that trip implementations: the segments use base64url without padding (no =), and the JWS signature is the raw 64-byte r||s form, not the DER form that derRepresentation produces, so use rawRepresentation on both sides. A verifier must (1) split into exactly three parts, (2) pin the algorithm to ES256 and reject none or anything else, (3) validate the x5c chain (next question), (4) verify the signature with the leaf's public key, and only then (5) decode and use the payload, also checking bundleId, environment (production versus sandbox) and appAppleId against your own app so a valid token for another app is rejected.

The check confirms the failure modes: the original verifies, swapping the payload for a "lifetime" product makes the signature invalid, alg: none is rejected, and a different key does not verify. It uses a key I generated, so the Apple chain, the real field names beyond what is shown, and Apple's own roots were not exercised. Prefer Apple's open source server libraries (Swift, Java, Node, Python) to hand-rolled code, which implement the chain check, the environment check and the models, and update with the API. Also never use a JWT library's "decode without verify" for entitlements, and log the transaction id, not the whole token.

The signature covers header and payload; the algorithm is pinned.

Common traps

  • Accepting whatever alg the header says. Pin ES256; "none" and key-confusion attacks go through otherwise.
  • Using the DER signature form. A JWS ES256 signature is the 64-byte raw form; DER will fail verification.

They might follow up with

  • Why also check the environment? Sandbox transactions are signed by the same infrastructure; accepting them in production lets testers get paid features.
  • Where do you get the public key? From the verified leaf certificate in x5c, not from a header you do not validate.

Related questions

  • 28.4.2 How do you validate the x5c certificate chain of an App Store JWS, and what does the leaf certificate prove?
  • 28.1.3 What does a .verified StoreKit result prove, what does it not prove, and when would you read…
  • 28.4.3 How do App Store Server Notifications V2 behave, and how do you process them idempotently and out of order?
  • 12.2.2 How do TLS and App Transport Security protect a request, and what is certificate pinning, and why is it…

How do you validate the x5c certificate chain of an App Store JWS… →