A backend engineer we'll call Marcus ships a Go service as a single static binary in a scratch image. No shell, no package manager, 14 MB on disk. It has been boring and reliable for two years, which is exactly what he wants from it. Then product asks for loyalty cards in Apple Wallet.
He finds a Go package for the job, wires it in on Tuesday, and the pass signs fine on his laptop. On Wednesday the staging deploy returns a 500 from the new route. The log line reads exec: "openssl": executable file not found in $PATH. The library has been quietly running a subprocess the whole time, and the one image he refuses to bloat is the one place it can't work.
Go is better placed for this job than most languages, because everything the signing step needs can live inside the process. This guide walks through that path: loading the certificate, signing the manifest, building the bundle, serving it with the right headers, and then the part no signing library covers. You can paste the code, and you'll know where it stops being worth owning.
What Go gives you, and the one thing it doesn't
A .pkpass is a ZIP archive. Inside it sit a pass.json, your image assets, a manifest.json listing the SHA-1 hash of every other file, and a signature file that proves the manifest hasn't changed since your server wrote it. Wallet checks all of it before it installs anything.
Go's standard library covers most of that. archive/zip builds the bundle, crypto/sha1 hashes each file, and encoding/json writes the manifest. What it lacks is a PKCS#7 signer, the format Apple expects for the signature file. That's the one gap you fill with a dependency.
Two small packages close it. The first reads your Pass Type ID certificate out of the .p12 file you exported from Keychain Access. The second builds a detached signature over the manifest. Both are pure Go, so CGO_ENABLED=0 keeps working and your image stays as small as it was.
The inputs come from the Apple Developer Program. You register a Pass Type ID such as pass.com.example.loyalty, create a certificate for it, and export that certificate with its private key as a single .p12. You also need Apple's WWDR intermediate certificate, which lets a phone walk your signature back to a root it already trusts. If the chain itself is new to you, the certificate chain explained covers what each link does.
Reading the .p12 without a subprocess
The obvious import for this is golang.org/x/crypto/pkcs12. Its own documentation marks the package as frozen and points to the SSLMate fork as the alternative, so start from the fork instead:
import (
"crypto"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
type Signer struct {
Cert *x509.Certificate
Key crypto.PrivateKey
WWDR *x509.Certificate
}
func LoadSigner(p12Path, password, wwdrPath string) (*Signer, error) {
raw, err := os.ReadFile(p12Path)
if err != nil {
return nil, err
}
key, cert, _, err := pkcs12.DecodeChain(raw, password)
if err != nil {
return nil, fmt.Errorf("decode p12: %w", err)
}
wwdrPEM, err := os.ReadFile(wwdrPath)
if err != nil {
return nil, err
}
block, _ := pem.Decode(wwdrPEM)
if block == nil {
return nil, fmt.Errorf("%s is not PEM encoded", wwdrPath)
}
wwdr, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
return &Signer{Cert: cert, Key: key, WWDR: wwdr}, nil
}DecodeChain returns the private key, the leaf certificate and any CA certificates bundled alongside it. The plainer Decode assumes the file holds exactly one certificate, and a Keychain export doesn't always honor that. The package accepts DER-encoded .p12 data only, which is what Keychain Access produces.
Apple distributes the WWDR certificate as a .cer file in DER form. If you hold the DER version, skip the pem.Decode step and hand the bytes straight to x509.ParseCertificate. The code above expects PEM, which is easier to keep in a secrets store as text.
Load the signer once at startup and keep it in memory. Parsing a .p12 on every request is wasted work, and it puts the password in reach of more code than it needs to be.
Signing the manifest
The signature is a detached PKCS#7 blob over the exact bytes of manifest.json. Detached means the manifest isn't embedded in the signature, because Wallet already has it in the archive. The smallstep/pkcs7 package, a fork of the older Mozilla one, does this in a handful of calls:
import "github.com/smallstep/pkcs7"
func (s *Signer) Sign(manifest []byte) ([]byte, error) {
sd, err := pkcs7.NewSignedData(manifest)
if err != nil {
return nil, err
}
sd.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256)
if err := sd.AddSigner(s.Cert, s.Key, pkcs7.SignerInfoConfig{}); err != nil {
return nil, err
}
sd.AddCertificate(s.WWDR)
sd.Detach()
return sd.Finish()
}The order matters in one place. The package documents that SetDigestAlgorithm should be called before you add signers, so the SHA-256 choice lands on the signer you attach. AddCertificate puts the WWDR intermediate into the blob, which is what lets the phone build the chain. Detach drops the content from the output.
Notice what is missing. There's no temporary .pem on disk, no password passed on a command line where any process on the host can read it, and no assumption about which openssl version the machine has. The manifest hashes themselves stay SHA-1, because that is what the pass format specifies. Only the signature uses SHA-256.
Building and serving the bundle
With a signer in hand, the rest is the standard library:
import (
"archive/zip"
"bytes"
"crypto/sha1"
"encoding/hex"
"encoding/json"
)
func BuildPkpass(files map[string][]byte, sign func([]byte) ([]byte, error)) ([]byte, error) {
manifest := make(map[string]string, len(files))
for name, data := range files {
sum := sha1.Sum(data)
manifest[name] = hex.EncodeToString(sum[:])
}
manifestJSON, err := json.Marshal(manifest)
if err != nil {
return nil, err
}
signature, err := sign(manifestJSON)
if err != nil {
return nil, err
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name string, data []byte) error {
w, err := zw.Create(name)
if err != nil {
return err
}
_, err = w.Write(data)
return err
}
for name, data := range files {
if err := add(name, data); err != nil {
return nil, err
}
}
if err := add("manifest.json", manifestJSON); err != nil {
return nil, err
}
if err := add("signature", signature); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}files holds pass.json and your images under their archive names, such as icon.png and logo.png. Marshaling a Go map sorts its keys, so the manifest bytes come out the same every time. That makes the output easy to test.
Serving the file is where a correct archive still fails:
import "net/http"
func (a *App) passHandler(w http.ResponseWriter, r *http.Request) {
member, err := a.members.FromSession(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
body, err := BuildPkpass(a.passFiles(member), a.signer.Sign)
if err != nil {
http.Error(w, "could not build pass", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/vnd.apple.pkpass")
w.Header().Set("Content-Disposition", `attachment; filename="card.pkpass"`)
w.Write(body)
}Send application/octet-stream instead and Safari treats the response as an ordinary download rather than handing it to Wallet. You get no error, only a file in the Downloads folder. Wallet also refuses to talk to a plain HTTP web service URL, so test against an HTTPS tunnel and not localhost.
Look at how the handler finds the member. It reads the session, not an ID in the path. A signed pass carries one person's name and barcode, and a route that returns whatever /passes/1042.pkpass asks for will happily hand 1043 to anyone who edits the URL.
Checking the bundle before a phone does
A bad signature fails quietly. Wallet shows a generic "cannot be installed" message and tells you nothing about which of the four files was wrong. Catch it in a Go test instead, where a failure has a name.
The same package that signs can parse and verify. Reattach the manifest to the parsed blob, since the signature is detached, and ask it to check:
import (
"testing"
"github.com/smallstep/pkcs7"
)
func TestSignatureVerifies(t *testing.T) {
manifest := []byte(`{"pass.json":"da39a3ee5e6b4b0d3255bfef95601890afd80709"}`)
signature, err := testSigner.Sign(manifest)
if err != nil {
t.Fatal(err)
}
parsed, err := pkcs7.Parse(signature)
if err != nil {
t.Fatal(err)
}
parsed.Content = manifest
if err := parsed.Verify(); err != nil {
t.Fatalf("signature does not verify: %v", err)
}
}This proves the signature matches the manifest. It can't prove Apple will trust the chain or that your pass.json is valid, which is why the last check should be a real device. Before you reach for one, drop the finished file into the pkpass validator, which checks the signature, the manifest hashes and pass.json. When the install still fails, the cannot-be-installed checklist covers the usual causes in order.
Run the test in CI with a throwaway certificate and not your production one. A signer test that needs the real .p12 is a test that eventually gets skipped.
The part the signing code never touches
At this point you can mint a valid pass. It will sit in someone's Wallet looking exactly as it did the day you signed it.
Changing a pass after install needs a web service you host. The pass carries a webServiceURL and an authenticationToken, and when a holder adds it, their phone registers a device and a push token with your server. You store those registrations. When a field changes, you send an empty APNs push to each registered token, and the phone calls back to ask which passes changed, then fetches each one again. Every fetched pass needs a fresh signature,. The update protocol, step by step lists each endpoint Wallet will call.
That is a device table to prune when passes are deleted, an APNs client with its own credentials, and a retry story for the pushes that fail. It's also the stretch where self-built implementations tend to stall. The first install works on day one, and the first update that never arrives shows up in week three. If a pass seems frozen, why a pass stops updating is the checklist to start from.
Two more items sit on the calendar rather than in the code. Your Pass Type ID certificate expires, and when it does every pass you sign afterward is rejected without an error that names the certificate. What breaks when the certificate expires walks through the failure. Google Wallet, meanwhile, is a separate pipeline. It skips the ZIP and the certificate and wants a JWT signed with a Google Cloud service account key, as described in the Google Wallet JWT flow.
Skipping all of it with an HTTP client
If the pass is a feature of your product and not the product itself, the other route is to let a hosted API do the signing, the updates and the second wallet. Go suits this well, because the standard net/http client is already everything you need. There's no SDK to wait for.
Passmint documents a Node SDK and a REST API, and for Go the REST API is the whole integration. Here is a small client that issues and updates a pass:
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
type Client struct {
APIKey string
HTTP *http.Client
}
func NewClient(apiKey string) *Client {
return &Client{APIKey: apiKey, HTTP: &http.Client{Timeout: 10 * time.Second}}
}
type Pass struct {
ID string `json:"id"`
URL string `json:"url"`
DownloadURL string `json:"download_url"`
}
func (c *Client) do(method, path, idempotencyKey string, body, out any) error {
payload, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(method, "https://api.passmint.com"+path, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
var e struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
json.NewDecoder(res.Body).Decode(&e)
return fmt.Errorf("passmint %d %s: %s", res.StatusCode, e.Error.Code, e.Error.Message)
}
return json.NewDecoder(res.Body).Decode(out)
}
func (c *Client) IssuePass(templateID, email, name string, fields map[string]string, key string) (*Pass, error) {
var pass Pass
err := c.do(http.MethodPost, "/v1/passes", key, map[string]any{
"template_id": templateID,
"holder_email": email,
"holder_name": name,
"field_values": fields,
}, &pass)
return &pass, err
}
func (c *Client) UpdatePass(passID string, fields map[string]string, key string) error {
var pass Pass
return c.do(http.MethodPatch, "/v1/passes/"+passID, key, map[string]any{
"field_values": fields,
}, &pass)
}Call IssuePass with a key derived from your own record, such as the member ID, and a retry after a timeout can't mint a second pass. The API replays the stored response for a repeated key. Reuse a key with a different body and you get a 409 instead, which tells you a bug has crept in rather than a network blip.
The response carries a url you can put in an email, a text message or a QR code. On an iPhone it installs into Apple Wallet and on Android it opens the Google Wallet save flow. One call replaces the certificate, the manifest, the web service and the JWT. The REST API reference has every field.
Choosing between the two
Signing in Go is a fair afternoon of work, and the code above is most of it. The cost isn't the afternoon. It's the calendar entry for the certificate, the device table, the APNs client and the second wallet that arrive after it.
Build it yourself when passes are the product, or when a compliance rule keeps signing keys inside your own infrastructure. You'll own a clean, dependency-light signer and the whole update path behind it. If you want the longer argument for each side, the build-or-buy comparison against passkit-generator lays it out for the Node ecosystem, and the trade-offs carry over.
Call an API when the pass is a feature, and your team would rather ship the loyalty program than maintain the pipeline under it. In Go that choice is cheap to reverse. The client is about eighty lines, and nothing about it leaks into the rest of your service.
Either way, the image stays small, the binary stays static and nothing shells out to a tool that isn't there. If you take the API route, the quickstart gets a test-mode key issuing a signed pass in about five minutes, and Passmint shows what the finished pass looks like on both wallets.
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 →