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 secretYou will end up with five things:
File or Secret | What it is |
|---|---|
| Your private key. Never share or commit it. |
| The CSR the Account Holder uploads to Apple. |
| The public certificate Apple returns. |
| The certificate and private key bundled for CI. |
| The text-safe version of the |
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.certSigningRequestDo not send this one:
developer-id-application.keyKeep 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.pemNow 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 sha1OpenSSL 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 \
-nooutThen 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 \
| pbcopyCreate these repository or environment secrets:
Secret | Purpose |
|---|---|
| The base64-encoded |
| The export password chosen for the |
| A random password for the temporary CI keychain. |
| The Apple ID used for notarization. |
| An Apple app-specific password—not the normal account password. |
| 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 make7. 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 configThe signing identity must match the certificate imported into CI. To copy its exact value, run:
security find-identity -v -p codesigningForge'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