All articles
// developers

Where to Store Your Apple Wallet Signing Certificate: Secrets Managers, CI, and the .env File Nobody Remembers

Your .p12 ends up in four places by accident. Here is where it should live, how to get it into CI and a container, and how to rotate it without missing a copy.

Updated
Where to Store Your Apple Wallet Signing Certificate: Secrets Managers, CI, and the .env File Nobody Remembers
Julio Song
10 min read

It's a Thursday afternoon and a new engineer, call her Dana, is trying to get pass signing working on her laptop. Nobody remembers who exported the certificate. She asks in chat. Within ten minutes she has three answers: a .p12 in a shared drive folder, a base64 blob in the CI settings, and a PASS_CERT line in somebody's old .env file that got pasted into a ticket. All three work. Two of them are different certificates.

That is the normal state of a Pass Type ID certificate a year after launch. Nobody decided to store it badly. Each copy was a reasonable shortcut on the day someone needed to get unblocked, and none of them was ever removed. You end up with a signing key that can issue passes under your name, scattered across places with very different access rules.

This guide is about narrowing that down. You'll pick one source of truth, decide how the key reaches the process that signs, and set up CI so the copy you forget about doesn't become the one that fails in November. The certificate itself, what it contains and how to get one, is covered in the certificate chain walkthrough. Here we only care about where it lives.

Why a signing key is not an API key

Most secrets you handle are credentials. Leak one and you rotate it, and the damage ends when the rotation does. A Pass Type ID private key is different in two ways.

First, anyone who holds it can sign passes that Wallet accepts as yours. There is no log on your side of passes someone else signs, so a leak can sit unnoticed for as long as the certificate is valid. Second, you can't rotate it quietly. Apple doesn't renew a Pass Type ID certificate in place. You request a new one, deploy it, and wait for every copy of the old one to stop being used. We cover that sequence in what breaks when the certificate expires.

So the goal isn't just secrecy. It's a short list of places the key exists, so you always know which ones to update. A key in five places is a rotation with five chances to forget one.

The four places it ends up by default

Before choosing a home, count where it already lives. In most codebases the list looks like this:

  • A repository. Usually a certs/ folder added "temporarily" during the first week. Deleting the file doesn't remove it from history.
  • A .env file. Fine on one laptop. Not fine when it's copied to a second laptop, a staging box and a ticket.
  • CI variables. Often a base64 string someone created once. Nobody owns it, so nobody updates it.
  • A shared drive or password manager. The original export, with a password in the same folder or in the same message.

Run a quick search for all of them. Look for .p12 and .pem files in the repository and its history, for PASS_CERT-style variable names in your CI settings, and for the export in any shared folder. You aren't cleaning up yet. You're building the list that your rotation checklist will use later.

git log --all --diff-filter=A --name-only --pretty=format: | grep -Ei '\.(p12|pem|key)$' | sort -u

If that prints anything, the key has been in your history since that commit. Treat the certificate as exposed to everyone who ever cloned the repository, and plan a rotation instead of a delete.

Pick one source of truth

Choose a single place that holds the canonical copy, and make every other location a pull from it. Two options cover most teams.

A cloud secrets manager. AWS Secrets Manager, Google Secret Manager, Azure Key Vault and HashiCorp Vault all store binary or text values, version them, and log reads. AWS documents a maximum secret value of 65,536 bytes for its binary field, which leaves plenty of room for a .p12 that is typically a few kilobytes. The version history is the feature you want most. When you rotate, the old version stays retrievable for a rollback, and the access log tells you which service read it.

Your platform's own secret store. If you deploy to a platform with encrypted environment variables, such as a container service or an edge runtime, that can be the source of truth for a small team. Check the per-value size limit first. Some stores cap values at a size that a base64-encoded .p12 can brush against, and a limit you learn about at deploy time is a bad surprise.

Whichever you pick, store the .p12 and its password as two separate secrets. Anyone who can read one should not automatically be able to read the other. The password is the only thing protecting the file if it leaks from a backup or a log.

Getting a binary file through a text-only channel

Environment variables and CI settings hold text, and a .p12 is binary. The standard answer is base64. Encode once, store the string, and decode at the point of use.

base64 < Certificates.p12 | tr -d '\n' > cert.b64

Strip the newlines. A wrapped base64 string pasted into a form field often loses or gains whitespace, and the decode then fails with an error that says nothing about the real cause. Single-line output avoids it.

On the other end, decode into memory if your signing library allows it, and into a temporary file only if it doesn't. In Node.js the buffer route looks like this:

import forge from 'node-forge'

const p12Der = Buffer.from(process.env.PASS_P12_BASE64!, 'base64')
const p12Asn1 = forge.asn1.fromDer(p12Der.toString('binary'))
const p12 = forge.pkcs12.pkcs12FromAsn1(
  p12Asn1,
  process.env.PASS_P12_PASSWORD!,
)

Nothing touches disk, so there's no file to forget to delete. If your library only accepts a path, write the decoded file to a temporary directory with owner-only permissions and remove it in a finally block. A crash that skips the cleanup leaves a private key in /tmp.

CI: the copy that fails in November

CI is where the forgotten copy lives, because pipelines run for months without anyone reading their settings. There are two separate jobs a pipeline might do with the certificate, and they deserve different treatment.

Building and testing. Most pipelines don't need the production key at all. Tests can sign with a throwaway certificate generated for the test run, since a test only has to prove your code produces a well-formed .pkpass. If a job doesn't need the real key, don't give it the real key. A secret that isn't in the job can't be printed by the job.

Deploying. If your pipeline pushes the certificate into a runtime, such as a secrets manager or a container, give only the deploy job access, and only on the branch you deploy from. GitHub documents a limit of 48 KB per secret, which a .p12 fits inside comfortably. It also doesn't pass repository secrets to workflows triggered from forked repositories, which is the behaviour you want here.

jobs:
  deploy:
    if: github.ref == 'refs/heads/main'
    environment: production
    steps:
      - run: echo "$PASS_P12_BASE64" | base64 -d > /tmp/cert.p12
        env:
          PASS_P12_BASE64: ${{ secrets.PASS_P12_BASE64 }}

Using a named environment with required reviewers gives you a place to put an approval step, so a pull request can't quietly start using the secret. The same idea exists in other CI systems under different names.

One trap deserves its own warning. CI systems mask secrets in logs by matching the exact value. A base64 string that gets transformed on the way to the log, split across lines or printed through a debug flag, may not match anymore. Don't rely on masking as the protection. Rely on never printing the value at all.

Containers and serverless

For long-running services, mount the secret at start-up and keep it in memory. Most platforms can inject a secret as an environment variable or as a file on a memory-backed volume. Prefer the volume when you can, because environment variables tend to show up in crash dumps and on the settings page of many dashboards.

For edge and serverless runtimes there is often no filesystem at all, which is one more reason to prefer the in-memory decode shown earlier. It also means signing must happen with a pure JavaScript or WebAssembly implementation, because a library that shells out to openssl has nowhere to run. If the platform's per-secret size limit is tight, test with your real certificate before you commit to the design, not with a placeholder that happens to fit.

A different approach avoids the problem entirely: don't give the web tier the key. A small signing service that accepts a manifest and returns a signature means only one deployment ever holds the certificate. Everything else holds a credential to call that service, which you can revoke in seconds. That's more moving parts than most teams want on day one, but it's the design to move toward if more than a couple of services need to sign.

The .env file, honestly. It isn't evil. For local development it's the simplest thing that works. The problem is what happens around it.

Keep it for development only, and make the local certificate a different one. A second Pass Type ID adds no cost beyond your developer membership, so make a development identifier with its own certificate and let developers use that. Passes signed with it are visibly separate from production passes, and a leaked laptop copy can't touch your real customers.

Then add the cheap protections. Put .env and *.p12 in .gitignore on day one. Add a pre-commit check that rejects those extensions. Turn on your host's secret scanning, but don't assume it understands a binary .p12 or a base64 blob, because scanners are built around the token formats of known providers and a certificate export has no fixed prefix to match. A scanner is a safety net, not a policy.

Rotation is a checklist, so write it down

Every year, Apple's certificate runs out and you get a new .p12. The question that decides how painful that is: can you list every place the old one lives?

Keep a short note next to your deploy docs with these lines:

  1. The canonical secret, and who can write to it.
  2. Every consumer that reads it, by service name.
  3. The CI secrets that mirror it, if any.
  4. The development certificate, and where it differs.
  5. A scheduled check on the expiry date, sent to a channel the team watches.

Then rotate in this order. Store the new certificate as a new version in the source of truth. Sign one test pass with it against a Pass Type ID you don't use in production. Roll out to consumers one at a time, and watch for signing errors after each. Only then remove the old copies, starting with the ones you least trust.

The expiry check can be a few lines. The date is a plain field on the file:

openssl pkcs12 -in pass-type-id.p12 -clcerts -nokeys -passin env:PASS_P12_PASSWORD | openssl x509 -noout -enddate

Run it from a scheduled job against the canonical secret, and alert when the date is within 45 days. Apple's own reminder email goes to whichever account requested the certificate, and that account is often one nobody reads.

What to do this week

You don't need a project for this. You need an afternoon.

Find every copy, using the search above and a look through your CI settings. Pick the source of truth and move the canonical file there, with the password stored separately. Replace the other copies with pulls from it, or delete them. Create a development Pass Type ID so nobody needs the production key on a laptop. Write the five-line rotation note. And put the expiry check on a schedule before you close the ticket.

If you would rather not own any of this, Passmint holds your Pass Type ID certificate on your behalf, signs passes without handing the key to your application, and tracks the expiry date so it stops living in an email. That keeps your own certificate out of the picture altogether.

Primary sources

Common questions

Keep one canonical copy in a secrets manager that versions values and logs reads, and store the .p12 and its password as two separate secrets. Make every other copy, in CI or a container, a pull from that source so a rotation has a short list of places to update.
Yes. A .p12 is a few kilobytes, well inside the 48 KB per-secret limit GitHub documents. Encode it as single-line base64, decode it at the point of use, and give the secret only to the deploy job rather than to builds and tests.
No. Deleting the file later does not remove it from git history, so anyone who ever cloned the repository has had the key. If it was committed, plan a certificate rotation instead of just deleting the file.
Keep a short rotation note listing the canonical secret, every service that reads it and any CI mirrors. Store the new certificate as a new version, sign a test pass, roll out one consumer at a time, and remove old copies last.
Julio Song

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 →

Related reading

Share this article