Hosted ondeporteencanal.hyper.mediavia theHypermedia Protocol

The Apple certificate was valid. GitHub Actions still couldn't sign my Electron appHow to create a Developer ID Application certificate that actually works in CI—without passing private keys between people or fighting Keychain Access.

Our macOS release workflow had everything it appeared to need:

    a valid Apple Developer certificate;

    the correct password;

    an Electron Forge signing configuration; and

    a GitHub Actions runner ready to build the app.

It still failed.

At one point macOS even reported:

security: SecKeychainItemImport: MAC verification failed during PKCS12 import (wrong password?)

The password was correct.

The real problem was that we had treated the certificate as if it were the complete signing identity. It isn't. To sign an app, CI needs both the certificate Apple issues and the private key used to create its certificate signing request.

Once that clicked, the solution became surprisingly simple: generate the private key and CSR yourself with OpenSSL, send only the CSR to the Apple Developer Account Holder, and build the CI-ready .p12 when Apple sends the certificate back.

This is the workflow that now signs our Electron app reliably.

The rule to remember: the person preparing the CI secret should generate the private key. Apple only needs the CSR; nobody else ever needs the key.

The 30-second version

private key + CSR → Apple Developer → .cer private key + .cer → .p12 → base64 → GitHub secret

You will end up with five things:

File or Secret

What it is

developer-id-application.key

Your private key. Never share or commit it.

developer-id-application.certSigningRequest

The CSR the Account Holder uploads to Apple.

developer_id_application.cer

The public certificate Apple returns.

developer-id-application.p12

The certificate and private key bundled for CI.

APPLE_CERTIFICATE_BASE64

The text-safe version of the .p12 stored in GitHub.

This guide is for Electron apps distributed outside the Mac App Store—for example as a .dmg, .zip, or auto-update artifact. Apple calls the certificate you need Developer ID Application. A Developer ID Installer certificate is for signing .pkg installer packages, not the app itself.

Why the usual certificate handoff breaks

A CSR is created from a private key. Apple signs the CSR and returns a certificate, but it never receives or recreates that key.

That means a certificate generated from someone else's CSR may be perfectly valid while still being useless to you: its matching private key lives in their Keychain, on their machine, or under another macOS user account.

This is why double-clicking a .cer can leave you with a certificate that appears in Keychain Access but cannot be exported as a working .p12.

The OpenSSL flow fixes the ownership problem:

    You generate and keep the private key.

    The Account Holder uploads your CSR to Apple.

    Apple returns the public certificate.

    You combine the certificate with its matching key.

No Keychain export from another person's Mac. No private key sent over Slack or email. No mystery about where the other half of the signing identity went.

1. Generate the private key and CSR

Run these commands on the machine that will prepare the GitHub Actions secret:

mkdir -p ~/developer-id-cert cd ~/developer-id-cert openssl genrsa -out developer-id-application.key 2048 chmod 600 developer-id-application.key openssl req -new \ -key developer-id-application.key \ -out developer-id-application.certSigningRequest \ -subj "/emailAddress=YOUR_APPLE_ID_EMAIL/CN=Developer ID Application Your Company/C=US"

Replace:

    YOUR_APPLE_ID_EMAIL with your Apple Developer email;

    Your Company with a recognizable company or project name; and

    US with the appropriate two-letter country code.

Send this file to the Apple Developer Account Holder:

developer-id-application.certSigningRequest

Do not send this one:

developer-id-application.key

Keep the private key in secure storage. You will need it whenever you recreate the .p12; if you lose it, Apple's .cer alone cannot recover it.

2. Have the Account Holder create the certificate

Apple currently requires the Account Holder role to create Developer ID certificates through the developer account. The Account Holder should:

    Click the + button.

    Under Software, select Developer ID.

    Choose Developer ID Application.

    Upload developer-id-application.certSigningRequest.

    Download the generated .cer file.

    Send the .cer back to you.

Apple's current instructions are documented in Developer ID certificates.

Notice what did not move between machines: the private key.

3. Combine Apple's certificate and your key into a .p12

Put the downloaded .cer beside the private key, then convert Apple's DER certificate to PEM:

cd ~/developer-id-cert openssl x509 \ -inform DER \ -in developer_id_application.cer \ -out developer_id_application.pem

Now create the PKCS#12 bundle:

openssl pkcs12 -export \ -inkey developer-id-application.key \ -in developer_id_application.pem \ -out developer-id-application.p12 \ -name "Developer ID Application: Your Company Name (TEAMID1234)" \ -certpbe PBE-SHA1-3DES \ -keypbe PBE-SHA1-3DES \ -macalg sha1

OpenSSL will ask you to choose an export password. Save it securely; GitHub Actions will need it as APPLE_CERTIFICATE_PASSWORD.

The three compatibility flags are intentional. Some combinations of OpenSSL and macOS produce or import modern PKCS#12 encryption differently. The bundle can then fail with the wonderfully misleading wrong password? message even when its password is right. Using these flags solved that import failure in our workflow.

4. Test the bundle before blaming CI

First verify that OpenSSL can read it:

openssl pkcs12 \ -info \ -in developer-id-application.p12 \ -noout

Then verify that macOS can import it. This example uses a disposable keychain so you do not need to modify your login keychain:

TEST_KEYCHAIN="$TMPDIR/developer-id-test.keychain-db" security create-keychain -p 'temporary-test-password' "$TEST_KEYCHAIN" security unlock-keychain -p 'temporary-test-password' "$TEST_KEYCHAIN" security import developer-id-application.p12 \ -k "$TEST_KEYCHAIN" \ -P 'YOUR_P12_PASSWORD' \ -T /usr/bin/codesign security find-identity -v -p codesigning "$TEST_KEYCHAIN"

The output should include an identity similar to:

Developer ID Application: Your Company Name (TEAMID1234)

When you are finished testing:

security delete-keychain "$TEST_KEYCHAIN"

If this works locally, you have removed several variables before the first GitHub Actions run.

5. Store the certificate in GitHub

GitHub secrets hold text, so encode the binary .p12 as one line of base64:

openssl base64 -A \ -in developer-id-application.p12 \ | pbcopy

Create these repository or environment secrets:

Secret

Purpose

APPLE_CERTIFICATE_BASE64

The base64-encoded .p12 currently in your clipboard.

APPLE_CERTIFICATE_PASSWORD

The export password chosen for the .p12.

APPLE_KEYCHAIN_PASSWORD

A random password for the temporary CI keychain.

APPLE_ID

The Apple ID used for notarization.

APPLE_ID_PASSWORD

An Apple app-specific password—not the normal account password.

APPLE_TEAM_ID

The Apple Developer Team ID.

The first three secrets are used to sign. The final three authenticate notarization. These are separate steps: signing proves who built the app, while notarization asks Apple to scan it.

Electron Forge also supports notarization with an App Store Connect API key or stored keychain credentials. Its macOS signing guide documents all three authentication options.

6. Import the signing identity in GitHub Actions

Add this before Electron Forge packages the macOS app:

- name: Import Apple signing certificate if: runner.os == 'macOS' env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} run: | CERTIFICATE_PATH="$RUNNER_TEMP/developer-id-application.p12" KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" echo -n "$APPLE_CERTIFICATE" | base64 --decode > "$CERTIFICATE_PATH" security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 1200 "$KEYCHAIN_PATH" security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security import "$CERTIFICATE_PATH" \ -k "$KEYCHAIN_PATH" \ -P "$APPLE_CERTIFICATE_PASSWORD" \ -T /usr/bin/codesign security set-key-partition-list \ -S apple-tool:,apple:,codesign: \ -s \ -k "$APPLE_KEYCHAIN_PASSWORD" \ "$KEYCHAIN_PATH" security list-keychains -d user -s "$KEYCHAIN_PATH" security find-identity -v -p codesigning "$KEYCHAIN_PATH" rm -f "$CERTIFICATE_PATH"

Then expose the notarization credentials to the build step:

- name: Build macOS app if: runner.os == 'macOS' env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: pnpm electron-forge make

7. Configure Electron Forge

Electron Forge signs and notarizes the app during its package step. A minimal TypeScript configuration looks like this:

import type { ForgeConfig } from '@electron-forge/shared-types' const config: ForgeConfig = { packagerConfig: { appBundleId: 'com.example.myapp', asar: true, }, } if (process.platform === 'darwin' && process.env.CI) { config.packagerConfig.osxSign = { entitlements: './entitlements.plist', entitlementsInherit: './entitlements.plist', identity: 'Developer ID Application: Your Company Name (TEAMID1234)', } config.packagerConfig.osxNotarize = { appleId: process.env.APPLE_ID || '', appleIdPassword: process.env.APPLE_ID_PASSWORD || '', teamId: process.env.APPLE_TEAM_ID || '', } } export default config

The signing identity must match the certificate imported into CI. To copy its exact value, run:

security find-identity -v -p codesigning

Forge's defaults work for many apps. Add custom entitlements only when your app needs them, and keep authentication values in environment variables rather than the Forge config.

How this maps to our real Electron app

In Seed Hypermedia, the implementation is split across:

    .github/workflows/release-desktop.yml, for production releases;

    .github/workflows/dev-desktop.yml, for development builds;

    frontend/apps/desktop/forge.config.ts, for signing and notarization;

    frontend/apps/desktop/entitlements.plist, for hardened-runtime entitlements; and

    frontend/apps/desktop/package.json, for the build scripts called by the workflows.

Putting these links near the end is deliberate: the general workflow is useful to any Electron project, while the repository gives readers a working implementation to inspect.

Rotating the certificate

When the certificate expires or needs to be replaced, repeat the same sequence:

    Generate a new private key and CSR.

    Have the Account Holder issue a new Developer ID Application certificate from that CSR.

    Combine the new .cer and its matching private key into a new .p12.

    Test the import locally.

    Replace APPLE_CERTIFICATE_BASE64 and, if changed, APPLE_CERTIFICATE_PASSWORD.

    Rerun the macOS workflow.

You normally do not need to change APPLE_KEYCHAIN_PASSWORD, APPLE_ID, APPLE_ID_PASSWORD, or APPLE_TEAM_ID when only the signing certificate changes.

Keep the previous certificate until a build using the new one has signed, notarized, and published successfully. Only then consider revoking it.

Troubleshooting

MAC verification failed during PKCS12 import

Do not immediately assume the password is wrong. First recreate the .p12 with the -certpbe PBE-SHA1-3DES, -keypbe PBE-SHA1-3DES, and -macalg sha1 compatibility flags shown above.

The certificate appears without a private key

The .cer was probably generated from a CSR whose private key lives somewhere else. Locate that key, or create a new key and CSR and have Apple issue a replacement certificate.

GitHub Actions reports no signing identity

Inspect the actual keychain used by the workflow:

security find-identity -v -p codesigning "$KEYCHAIN_PATH"

Confirm that the imported identity matches the one in osxSign.identity. Also make sure the keychain was added to the user search list.

Signing succeeds but notarization fails

That is a different credential path. Confirm that:

    APPLE_ID_PASSWORD is an app-specific password;

    APPLE_TEAM_ID belongs to the team that owns the app; and

    the Apple ID can notarize software for that team.

The lesson I wish I had started with

Apple's .cer is not the secret that signs your app. The private key is.

Once you design the handoff around that fact, the entire process becomes predictable:

Generate the key yourself. Share only the CSR. Reunite Apple's certificate with the key. Give CI the resulting .p12.

If your certificate looks valid but GitHub Actions still cannot sign your Electron app, check who created the CSR—and where its private key lives. That question will usually save more time than trying the password again.

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime