A developer we'll call Dana is three hours into wiring up loyalty passes for a Django app, following a five-year-old tutorial for wallet-py3k. The library's README wants three openssl commands run by hand to turn a .p12 export into a certificate and key pair, then a fourth to test the signature. The third command fails silently on her machine because the container image she deploys to doesn't ship an openssl binary at all, and the traceback that surfaces two weeks later, in production, is a FileNotFoundError with no obvious connection to a wallet pass.
That's not a Django bug or a bad export from Keychain Access. It's what happens when a Python library treats the operating system's own OpenSSL install as a hidden dependency. Every widely used option for generating an Apple Wallet pass in Python, wallet-py3k, applepassgenerator, django-walletpass, either shells out to the openssl binary directly or assumes you'll run it by hand first. None of that is necessary. Python's own cryptography package has shipped everything the signing step needs since 2020, in pure Python, with no subprocess call and no assumption about what's installed on the host.
Getting the certificate and the manifest right is only the first problem anyway. A signed pass still needs the correct headers to reach a phone, and a running web service to update it after that, neither of which any of these libraries touch.
What generating a pass in Python actually involves
An Apple Wallet pass is a signed ZIP archive with a .pkpass extension. Inside it: a pass.json holding the fields, colors, and barcode, a set of image assets, a manifest.json that stores a SHA-1 hash of every file in the bundle, and a detached signature file proving the manifest hasn't been altered since your server produced it. Wallet refuses to install the archive unless all four check out.
Producing that signature needs two certificates: your own Pass Type ID certificate, issued through the Apple Developer Program, and Apple's WWDR intermediate certificate, which chains your signature back to a root iOS already trusts. Getting the first means paying the $99-a-year developer fee, registering a Pass Type ID such as pass.com.example.loyalty under Certificates, Identifiers & Profiles, and exporting the resulting certificate and its private key from Keychain Access as a single .p12 bundle. Every Python tutorial for this stops here and hands you a wall of openssl commands to turn that bundle into something your code can read. That handoff is where the trouble starts, not where it ends.
The certificate chain, once
The Apple Developer Program membership is what lets you register a Pass Type ID and download a signing certificate for it. That certificate expires every year, on a date nobody puts on a calendar, and every pass you try to sign after it lapses fails without any error message pointing at the certificate as the cause.
The WWDR intermediate is steadier. The current one, AppleWWDRCAG4, has been valid since December 16, 2020 and doesn't expire until December 10, 2030, so once you download it from the developer portal you can treat it as a fixture rather than something to track. It's the two files you're responsible for renewing yourself, your Pass Type ID certificate and its private key, that actually rotate.
Both of those, plus the WWDR certificate, need to reach your Python process somehow. The question a Python developer hits immediately, and that every wallet-py3k-era tutorial answers the same way, is how to get the private key out of a .p12 bundle without running a separate command line tool first.

Loading the .p12 without shelling out
cryptography's own pkcs12 module has read .p12 files directly since version 2.5, in a single function call:
from cryptography.hazmat.primitives.serialization import pkcs12
with open("Certificates.p12", "rb") as f:
p12_data = f.read()
private_key, certificate, additional_certs = pkcs12.load_key_and_certificates(
p12_data, password=b"your-export-password"
)That's the entire conversion. No openssl pkcs12 -nocerts, no separate PKCS#8 reformatting step, no intermediate .pem file sitting on disk that a build script has to remember to clean up. private_key and certificate come back as native cryptography objects, ready to hand to the signing step, and the password never has to leave your own process.
Compare that to how wallet-py3k, still the most-cited Python option for this, actually signs a manifest. Its signer builds an openssl smime command as a list of arguments, including -passin pass:<your password>, and runs it with subprocess.Popen. That has two consequences worth knowing about before you adopt it. The certificate password sits in plain text as a command line argument, visible to anything on the same machine that can read the process list. And the library now has a runtime dependency, the openssl binary being present and on the PATH, that Python's own tooling has no way to express or install for you. A container image, a Lambda layer, or a locked-down CI runner without it fails at the exact line Dana's did, with a FileNotFoundError that says nothing about certificates.
Signing the manifest with Python's own cryptography library
The signature itself is a detached PKCS#7 (also called CMS) blob over the manifest's bytes, produced with your Pass Type ID certificate and key, and including Apple's WWDR certificate so a phone can walk the chain back to a trusted root. cryptography's pkcs7 module builds exactly that, and has since version 3.2:
from cryptography.hazmat.primitives.serialization import pkcs7
signature = (
pkcs7.PKCS7SignatureBuilder()
.set_data(manifest_bytes)
.add_signer(certificate, private_key, pkcs7.hashes.SHA256())
.add_certificate(wwdr_certificate)
.sign(
pkcs7.serialization.Encoding.DER,
[pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.NoCapabilities],
)
)DetachedSignature tells the builder not to embed the manifest bytes inside the signature, since Wallet already has them as manifest.json in the archive. NoCapabilities drops S/MIME capability attributes that have no meaning for a wallet pass and that some parsers reject. The manifest hash itself is SHA-1 per Apple's spec, computed per file with hashlib.sha1(file_bytes).hexdigest() before any of this runs, while the signature algorithm over that manifest can use SHA-256.
This is the whole signing step, and it never leaves the Python process. No file paths to a temporary .pem, no password on a command line, no assumption that the host has openssl installed at a particular version. It also means the same code runs identically on a laptop, in a Docker container built from python:slim, and inside a serverless function, which is precisely the set of environments a subprocess.Popen(["openssl", ...]) call can't promise.

Zipping the pass and serving it from Django
With pass.json, the image assets, manifest.json, and the signature in hand, the archive itself is just a ZIP file with those four written in, built with the standard library's own zipfile module. Nothing about that step needs a third-party package at all.
Serving it is where a lot of otherwise-correct signing code still fails. A Django view has to compute the pass at request time, since a real pass carries one customer's loyalty number and barcode, not a static file, and it has to return exact headers:
from django.http import HttpResponse
def download_pass(request, member_id):
member = get_object_or_404(Member, pk=member_id)
pkpass_bytes = build_signed_pass(member)
response = HttpResponse(
pkpass_bytes, content_type="application/vnd.apple.pkpass"
)
response["Content-Disposition"] = f'attachment; filename="{member.slug}.pkpass"'
return responseGet the Content-Type wrong, even something as close as application/octet-stream, and Safari treats the response as a generic download instead of handing it to Wallet, with no error to tell you why. The route also has to run over HTTPS, since Wallet won't register a device against a plain HTTP web service URL, which in practice means testing against a real deployment or a tunnel rather than runserver on localhost.
Authorization deserves its own line here, because a route that already looks locked down usually isn't. A member_id in the URL is not a secret, and if your primary keys are sequential integers, a view that hands back a signed pass for whatever ID shows up in the path will happily return someone else's loyalty card to anyone who edits the number. Check the request against request.user or require a short-lived signed token in the query string instead of trusting the path alone.
The web service protocol Python's libraries don't touch
None of wallet-py3k, applepassgenerator, or the signing code above does anything past producing bytes. A pass sitting in someone's Wallet stays exactly as it was signed until your server implements Apple's update protocol separately.
When a holder adds a pass, their phone registers a device and push token against a web service URL baked into pass.json. When a field changes, your server sends an empty APNs push, just a wake-up call with no payload, to that stored token. The push has to carry the pass type identifier as its APNs topic rather than an app's bundle ID, and it can be authenticated either with the same Pass Type ID certificate you sign passes with or with a separate token-based .p8 key, both of which are one more piece of Apple infrastructure with nothing to do with generating a .pkpass. Wallet then calls back into your server for the list of passes that changed and fetches each one again, fully re-signed. django-walletpass is the one package in this ecosystem that attempts the server side of this rather than generation alone, and it does its own signing through cryptography rather than a shelled-out openssl call. It still commits you to Django specifically, to APNs credentials of your own, and to a device registration table you're responsible for pruning when passes get deleted.
applepassgenerator, for its part, hasn't shipped a release since early 2023. That's common enough in this space that betting a production integration on any single one of these packages is a real risk, not a hypothetical one.
And then Google Wallet, from the same service account
Google Wallet skips the ZIP-and-certificate model. Instead of a .pkpass, you build a JSON pass object, sign it into a JWT with a Google Cloud service account key, and hand the holder a save link. Python's google-auth package does the signing:
from google.auth import crypt, jwt
signer = crypt.RSASigner.from_service_account_file("service-account.json")
claims = {
"iss": service_account_email,
"aud": "google",
"typ": "savetowallet",
"origins": ["example.com"],
"payload": {
"genericClasses": [pass_class],
"genericObjects": [pass_object],
},
}
token = jwt.encode(signer, claims).decode("utf-8")
save_url = f"https://pay.google.com/gp/v/save/{token}"There's no manifest, no detached signature, no WWDR chain, and no reuse of anything from the Apple side. The service account comes from a Google Cloud project rather than the Apple Developer portal, and the identifiers are different too: an issuer ID for your Google Wallet account and a class ID for the pass type, in place of a Pass Type ID and a Team Identifier. Updates work by patching the pass object directly through Google's REST API instead of an APNs round trip, which is a genuinely simpler model, since there's no wake-up push and no separate fetch-what-changed call. It's still a second implementation, though, with its own credentials and its own failure modes, sitting next to whatever you just built for Apple.

Where this actually leaves you
Total it up: a certificate you renew yourself every year, a signing step that's finally free of the openssl binary but still yours to maintain, correct headers on the Django route that serves the file, a device registry and an APNs client for updates, and a second, unrelated pipeline for Google Wallet with its own service account. None of the Python packages in this space cover more than the first piece, and the most established one still runs a password through a command line argument to get there.
That's a fair trade when passes are the actual product a team is building. It's a much harder one for a Django app that needs a loyalty card to show up correctly on two platforms and would rather not own a certificate renewal calendar and an APNs client to get there.
If your stack is Python and you'd rather send one requests.post than maintain a signing pipeline for each wallet, Passmint's REST API takes a template ID and a set of field values from any language that can make an HTTP request, and returns a signed pass on both platforms along with the update service behind it.
Primary sources
Common questions
Technical content writer, Passmint
Julio is a technical content writer at Passmint. He writes about Apple PassKit, the Google Wallet API, and what breaks when wallet passes meet production traffic.
More from Julio Song →