Engineering // Wallet
Signing Apple Wallet passes without seeing the card
A Pass Type ID private key cannot ship inside an iOS app. Pass signing therefore has to happen on a server. That server should not get to see your loyalty cards. Both constraints are satisfiable at the same time, and the resolution is more boring than it sounds.
The constraint
A .pkpass is a zip containing pass.json, some images, a manifest.json mapping each filename to the SHA-1 digest of its contents, and a signature file. The signature is a detached PKCS#7 over the manifest bytes, made with an Apple-issued Pass Type ID certificate.
The obvious implementation is to ship that certificate and its private key inside the app and sign locally. Do not do this. An iOS binary is not a secret store: anyone can pull the app, extract the key, and mint passes that are cryptographically indistinguishable from yours. Apple will revoke the certificate, and every pass ever issued under it stops validating.
So the key lives on a server. The question is what else the server gets to learn.
The obvious server design is bad
The path of least resistance is to send the card to the server and let it build and sign the whole pass. It works, it is simple, and it means an app whose entire pitch is that it collects nothing now operates a service that receives every loyalty card number its users own.
That is not a privacy policy problem. It is an architecture problem: once the data arrives, the only thing preventing its retention is a promise, and promises are not enforceable by users.
The split
The signature covers the manifest, not the pass. The manifest is only filenames and digests. So the pass never has to leave the device:
- The app assembles the pass bundle locally, on device.
- It hashes each file and builds
manifest.json. - It sends only the manifest to the signing endpoint.
- The endpoint returns a detached PKCS#7 signature over those exact bytes.
- The app zips the signature into the bundle and hands it to Wallet.
The whole request body looks like this, and this is genuinely all of it:
{
"pass.json": "9c1185a5c5e9fc54612808977ee8f548b2258d31",
"icon.png": "3f786850e387550fdab836ed7e6dc881de23001b",
"logo.png": "da39a3ee5e6b4b0d3255bfef95601890afd80709"
}
No card number, no barcode payload, no store name, no colours, no user identifier. Filenames and digests. The server cannot render a pass, cannot tell a Tesco card from a library card, and cannot tell two users apart.
The signing side
It is a stateless Cloudflare Worker. It validates that the body really is a pass manifest before doing any crypto, which also makes it useless as a general-purpose signing oracle:
const valid =
manifest &&
typeof manifest === "object" &&
!Array.isArray(manifest) &&
Object.values(manifest).every(
(v) => typeof v === "string" && /^[0-9a-f]{40}$/.test(v)
);
Then a detached signature, with the Apple WWDR intermediate included so the chain validates on device:
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(contentBinary);
p7.addCertificate(wwdrCert);
p7.addCertificate(signerCert);
p7.addSigner({
key: signerKey,
certificate: signerCert,
digestAlgorithm: forge.pki.oids.sha256,
authenticatedAttributes: [
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
{ type: forge.pki.oids.messageDigest },
{ type: forge.pki.oids.signingTime, value: new Date() },
],
});
p7.sign({ detached: true });
The certificate, its key and the WWDR intermediate are Worker Secrets. There is no database, no logging of request bodies, and nothing to retain: the Worker's entire job is to turn 200 bytes of digests into 3 kB of signature.
What this does and does not buy you
Worth being precise, because it is easy to oversell.
It does mean card contents are never transmitted. Not encrypted in transit and decrypted server-side, not retained briefly, not sent at all. The bytes never leave the phone, so a compromised server, a subpoena, or a future change of heart by the operator finds nothing to take.
It does not make the digests magic. SHA-1 is used because Apple's pass format mandates it in the manifest, not because it was chosen as a security boundary; the security property here comes from never sending preimages, not from the hash's collision resistance. And an operator who wanted to misbehave still controls the signature, so they could refuse service or sign something else. What they cannot do is learn what is on the card, because they were never sent it.
The useful question about a privacy claim is not "what does the policy say" but "what would an attacker who owned the server get". Here the answer is: a list of SHA-1 digests and a count of how many passes were made.
Two things that cost time
The bytes must match exactly. The signature covers the manifest bytes as serialised by the app. Re-serialising server-side, even in a way that produces equivalent JSON, changes the bytes and produces a pass Wallet silently refuses. Sign the exact buffer that was hashed.
Detached actually matters. An embedded PKCS#7 will parse fine in your tests and be rejected on device. Wallet expects the manifest and the signature as separate files in the zip.
Why bother
Loyal costs 99p once and has no analytics, no ad SDKs, and no account system. That is only credible if the architecture makes the alternative impossible rather than merely disallowed. This is the one network call the app makes, so it is the one place the claim could quietly fail. It seemed worth building so that it cannot.
Loyal is a small iOS app from Distyll that puts loyalty cards into Apple Wallet. It is on the App Store for 99p, and the signing Worker described here is in the repository.