Skip to main content
    Code Signing

    How to Code Sign an Electron App (Windows and macOS)

    Sign Electron builds with electron-builder: which certificate each OS needs, why PFX files no longer work, the CI session trap, and macOS notarization.

    MS
    My-SSL Team
    ·
    16 min read
    ·
    Published August 6, 2026
    ·
    Last updated August 6, 2026

    The short answer

    An Electron app needs two different certificates, one per platform. On Windows you sign with an OV or EV code signing certificate from a publicly trusted CA, and since 1 June 2023 its private key has to live on certified hardware — a token, an HSM, or a cloud signing service — so the .pfx file that most electron-builder tutorials still show can no longer be issued. On macOS that certificate is useless: Apple accepts only a Developer ID Application certificate, followed by notarization. electron-builder drives both, but the Windows half is configured through win.signtoolOptions or win.azureSignOptions, never through certificateFile.

    One Electron app, two separate certificate systemsTwo parallel columns split by a highlighted divider reading "no certificate crosses this line". The Windows column runs from an OV or EV code signing certificate issued by a public CA, with its key on certified hardware, through an Authenticode signature applied by SignTool, to the SmartScreen check. The macOS column runs from a Developer ID Application certificate issued by Apple to an Apple Developer Program member, through codesign with the hardened runtime, through notarization by Apple and stapling, to the Gatekeeper check.One app, two certificates that cannot substitute for each otherWindows build (.exe installer)OV or EV code signing certificatepublic CA · key on token, HSM or cloudAuthenticode signatureSignTool, driven by electron-builderSmartScreen decidespublisher named · reputation accruesno certificate crosses this linemacOS build (.app / .dmg)Developer ID Application certificateissued only by Apple, to program memberscodesign + hardened runtimethen notarized by Apple, then stapledGatekeeper decidesunnotarized apps refuse to open
    Buying one certificate and expecting it to cover both downloads is the most expensive mistake in Electron distribution — the two columns share nothing but your app.

    Electron signing has a documentation problem. The framework has been around long enough that the top results still describe a workflow that stopped being possible in 2023: download a PFX, drop the path into certificateFile, set a password, ship. Every part of that is now wrong for a publicly trusted certificate, and following it costs a day before the error message even hints why. This guide covers what actually works in electron-builder 26 — which certificate each platform needs, the three ways to reach a hardware-held key, the CI constraint that catches teams after they have already bought, and the extra macOS steps that have nothing to do with the certificate at all.

    Which certificate does an Electron app need?

    One per platform you ship, and they come from different places. Windows needs an OV or EV code signing certificate from a CA in the Microsoft Trusted Root Program. macOS needs a Developer ID Application certificate, which only Apple issues and only to Apple Developer Program members. Linux builds need neither — AppImage and .deb packages are trusted through repositories and checksums instead.

    The Windows certificate

    OV and EV both produce a valid Authenticode signature and both replace the "Unknown Publisher" line in the UAC prompt with your organization name. The difference shows up in SmartScreen: a fresh OV signature starts with no reputation and earns it as copies are downloaded and run without incident, while EV validation carries weight from the first release. If your installer reaches a handful of internal users, OV is fine. If a red SmartScreen block during launch week would cost you real signups, EV shortens that period. My-SSL sells both as Certum code signing certificates, and the EV versus OV comparison goes through the validation difference in detail.

    What is no longer negotiable is where the key lives. Since 1 June 2023 the CA/Browser Forum has required every publicly trusted code signing key — OV as well as EV — to be generated inside hardware meeting FIPS 140-2 Level 2 or Common Criteria EAL4+, with export disabled. In practice a CA hands you a USB token, a cloud signing subscription, or an HSM integration, and there is simply no file to download. Certificates issued or renewed on or after 1 March 2026 also carry a 460-day maximum validity, so plan the renewal into your release calendar.

    The macOS certificate

    Apple runs its own CA for this and does not recognise anyone else's. A Developer ID Application certificate requires an active Apple Developer Program membership, and it is the only certificate Gatekeeper will accept for an app distributed outside the Mac App Store. Signing is also not sufficient on its own — the app has to be notarized by Apple afterwards. No public CA, including the one that issues your Windows certificate, can substitute for any part of that chain.

    What electron-builder actually signs

    On Windows it signs the packaged executable, any native binaries bundled beside it, and then the installer that wraps them — the installer last, because it embeds the files it has just signed. It does not sign your JavaScript. That lives in an asar archive, which Authenticode has no way to attach a signature to, and it does not sign the auto-update metadata either.

    Which files in an Electron build carry a signatureA build output tree. Signed items: the packaged application executable, any bundled native helper binaries and DLLs, and, highlighted, the NSIS installer that users actually download. Unsigned items shown with dashed borders: latest.yml and the blockmap files used by the auto-updater, and the app.asar archive, which are protected by hashes and HTTPS rather than by Authenticode.Signed, and deliberately not signedAuthenticode-signedMyApp.exethe packaged app inside the install folder*.dll · bundled native helpersnested signatures, one pass eachMyApp-Setup-1.4.0.exethe NSIS installer users download —the one SmartScreen judgesSigned last, after the app it wraps.Not signed — protected another waylatest.yml · *.blockmapupdate feed — trusted via HTTPS and thehashes it carries, not via a certificateresources/app.asaryour JavaScript — an archive, not a PE file,so Authenticode has nothing to attach toAnyone who can modify the install folder canswap app.asar without breaking a signature.Signing proves who shipped it, not what it does.
    The installer is the artefact that earns reputation, so a build that signs the inner executable but not the setup file still greets users with a warning.

    Two consequences are worth internalising before you ship. First, the artefact that matters commercially is the installer: it is what users download, what SmartScreen forms an opinion about, and what accumulates reputation. A build that signs the inner executable but not the setup file shows the same warning as an unsigned build. Second, an Authenticode signature is a statement about origin, not about behaviour. It proves the installer left your build machine unaltered; it does not stop anyone with write access to the installed folder from replacing app.asar afterwards.

    The update path deserves its own note. electron-updater checks the publisher name on a downloaded installer against the one recorded at install time before it runs anything, which is what stops a hijacked update feed from pushing an arbitrary binary. Keep publisherName in your Windows configuration matching the CN on your certificate, and remember that switching certificates changes that value — a mismatch stops updates silently on machines running the older release.

    Configure Windows signing in electron-builder

    In electron-builder 26 the Windows signing settings sit under win.signtoolOptions for certificate-based signing and win.azureSignOptions for Microsoft Trusted Signing. The two are mutually exclusive; set both and Trusted Signing takes over. Because your key is on hardware, you identify the certificate rather than supply it.

    Three ways electron-builder can reach a Windows signing keyA three-row comparison. Row one: signtoolOptions with certificateSubjectName or certificateSha1, which signs through a certificate already visible in the Windows certificate store, and runs only on Windows. Row two, highlighted: azureSignOptions, which calls Microsoft Trusted Signing over the network and is the only row that authenticates without a person present. Row three: a custom sign hook, which shells out to any tool such as jsign or a CA-supplied signing utility, and is the fallback when the first two do not fit.Pick the row that matches where your key livesCONFIG KEYREACHES THE KEY VIAUNATTENDED?win.signtoolOptionscertificateSubjectName / certificateSha1Windows certificate storetoken or cloud CSP puts it thereOnly if the sessionis already openwin.azureSignOptionsMicrosoft Trusted SigningHTTPS call to a signing accountno key material on the runnerYeswin.signtoolOptions.signcustom hook — a JS file you writewhatever you shell out tojsign, a CA signing tool, an HSM CLIDepends on the tool
    The unattended column is the one that decides your pipeline design — it is why teams with a USB token end up maintaining a build machine.

    Signing against a certificate in the Windows store

    A USB token's middleware, or a cloud signing client such as Certum's SimplySign Desktop, presents the certificate to Windows as if it came from a smart card, so it appears in the Current User personal store. SignTool can then find it by subject name or by thumbprint, and the private key operation happens inside the token or the remote HSM. That makes the configuration short:

    electron-builder.yml — certificate already visible to Windows
    win:
      publisherName: "Example Software Sp. z o.o."   # must match the CN on the cert
      signtoolOptions:
        certificateSubjectName: "Example Software"     # substring of the subject
        signingHashAlgorithms: ["sha256"]
        rfc3161TimeStampServer: "http://time.certum.pl"

    Use certificateSha1 with the thumbprint instead of certificateSubjectName if more than one certificate in the store could match — during a renewal overlap, for instance, when the old and new certificates share a subject and whichever SignTool picks first is a coin toss. Point rfc3161TimeStampServer at your own CA's endpoint rather than a shared public one; busy pipelines hit rate limits on the popular endpoints and the failure surfaces as an intermittent, confusing build error.

    Signing with Microsoft Trusted Signing

    Trusted Signing is Microsoft's own service rather than a certificate you hold, and electron-builder talks to it directly. No key material touches the build machine, and it authenticates with Azure credentials, which is what makes it usable on a hosted runner. The trade-off is that it ties your release process to an Azure subscription and to Microsoft's eligibility rules; the comparison with a conventional certificate covers where each one fits.

    electron-builder.yml — Microsoft Trusted Signing
    win:
      azureSignOptions:
        publisherName: "Example Software Sp. z o.o."
        endpoint: "https://eus.codesigning.azure.net"
        codeSigningAccountName: "example-signing"
        certificateProfileName: "example-public-trust"

    One version note before you copy any of this into a long-lived project. electron-builder 27, in alpha as of August 2026, replaces both keys with a single win.sign object carrying a type field that selects the backend. If you are on 26 today, write it the way above and run the project's schema migration when you upgrade — the shape changes, the concepts do not.

    When you need a custom sign hook

    Some signing setups cannot be expressed as SignTool arguments at all: a PKCS#11 module reached through jsign, a CA-supplied signing binary that replaces SignTool, or an HSM CLI that wants its own flags. For those, win.signtoolOptions.sign takes the path of a JavaScript file that electron-builder calls once per file it wants signed, handing you the path and the resolved certificate details.

    customSign.js — shell out to any signing tool
    const { execFileSync } = require("node:child_process");
    
    // electron-builder calls this once for every file it wants signed.
    exports.default = async function sign(configuration) {
      execFileSync(
        "jsign",
        [
          "--storetype", "PKCS11",
          "--keystore", process.env.PKCS11_CONFIG,
          "--storepass", process.env.TOKEN_PASSWORD,
          "--alias", process.env.CERT_ALIAS,
          "--tsaurl", "http://time.certum.pl",
          "--tsmode", "RFC3161",
          configuration.path,
        ],
        { stdio: "inherit" },
      );
    };

    Three things go wrong here often enough to be worth stating. The hook is invoked for nested files as well as the installer, so it has to be idempotent and quick — a tool that prompts, or that takes ten seconds of handshaking per call, turns a build into a coffee break. It has to throw on failure: returning normally after a failed signing command produces a green build with an unsigned installer, which is the worst of both outcomes. And the secrets it reads should come from the environment, never from the file itself, because this file lives in your repository.

    Signing Electron builds in CI/CD

    Whether a hosted runner can sign at all depends on how your certificate authenticates, not on which CI system you use. Anything that needs a person — a USB token in a physical port, or a cloud service that opens a signing window after a code from a phone app — cannot run unattended. Only backends that authenticate with a stored secret work on GitHub-hosted or GitLab-shared runners.

    Why an interactive signing session breaks a hosted CI runnerTwo timelines. The top one shows an interactive cloud signing session: a one-time code from a phone app opens a signing window of about two hours, after which signing stops until a person repeats it, so a build starting later fails. The bottom timeline, highlighted, shows API-key signing: the runner authenticates with a stored secret on every build, so any build at any hour signs successfully.A signing window is not a credentialInteractive session (phone code, token middleware)Person enters one-time codesession opens · about 2 hoursBuilds in the windowsign normallyThe 2 a.m. release buildno certificate found · pipeline redAPI-key signing (cloud HSM service)Secret stored in the runnerno human stepBuilds signkey stays in the HSMThe 2 a.m. release buildsigns tooCheck how your certificate authenticates before you design the pipeline around it, not after.
    This is the difference that decides whether you can use a hosted runner at all — and it is a property of the certificate you buy, not of your CI tool.

    Certum's cloud signing is a good illustration because the mechanics are typical. SimplySign Desktop connects using a one-time code generated by the SimplySign mobile app and then keeps the certificate available for a couple of hours. Inside that window, SignTool and therefore electron-builder work exactly as they would with a local token. Outside it, the certificate is simply not in the store and the build fails with a "no certificates were found" error that says nothing about sessions. It is an excellent developer-workstation setup and a poor fit for a nightly pipeline.

    That leaves three workable pipeline shapes, and it is worth picking one before you buy rather than after. A self-hosted Windows runner with the token attached and the middleware session kept open is the cheapest path if you already have a machine to spare. A cloud HSM service with API-key authentication — Microsoft Trusted Signing, or an HSM-backed KSP from your CA — is the only option that runs on a hosted runner without a caretaker. Or you split the pipeline: build and test in CI, then sign and publish from a supervised release step, which keeps the release deliberate at the cost of a manual gate. Cloud signing versus a USB token compares the operational cost of each, and code signing in CI/CD covers the pipeline design rules in general.

    Fail the build when signing fails. electron-builder will happily produce an unsigned installer if the certificate cannot be found, and a release job that only checks the exit code of the packaging step can publish it. Add an explicit verification step after packaging — the next section has the command — and let that decide whether the artefact is uploaded.

    Sign and notarize the macOS build

    macOS takes four steps, and signing is only the first. The app is signed with a Developer ID Application certificate under the hardened runtime, uploaded to Apple for notarization, then the returned ticket is stapled to the disk image so Gatekeeper can verify it offline. electron-builder performs all three when it is configured to, and it needs a Mac to do it.

    The four macOS steps between a build and a clean launchA left-to-right flow of four steps. Step one: codesign the app with the Developer ID Application certificate and the hardened runtime. Step two: upload it to Apple for notarization. Step three, highlighted: staple the returned ticket to the disk image so the check works offline. Step four: Gatekeeper accepts the app on first launch. A note explains that skipping the staple leaves the app dependent on the user having a working network connection at first launch.Signing is step one of four on macOS1. SignDeveloper ID +hardened runtime2. Notarizeupload to Apple,wait for the ticket3. Stapleattach the ticketto the .dmg4. Gatekeeperopens withouta warningSkip step 3 and Gatekeeper has to ask Apple over the network at first launch — which failsfor offline users and looks identical to an unnotarized app.electron-builder runs steps 1 to 3 for you; step 4 is the reward.
    Notarization is a scan by Apple, not a second signature — which is why it can pass on Tuesday and be revoked on Thursday if malware is later found.

    Hardened runtime is a precondition, not an option: Apple refuses to notarize an app without it. Electron apps usually need a couple of entitlements alongside it, because the hardened runtime blocks the JIT and unsigned executable memory that Chromium relies on. Both go in a plist referenced from the mac configuration.

    electron-builder.yml — macOS signing and notarization
    mac:
      hardenedRuntime: true
      gatekeeperAssess: false
      entitlements: build/entitlements.mac.plist
      entitlementsInherit: build/entitlements.mac.plist
      notarize:
        teamId: "ABCDE12345"

    Credentials stay out of the file. The app-specific password route wants an Apple ID, an app-specific password generated at appleid.apple.com, and the team ID, supplied through the environment variables electron-builder's notarization documentation lists. For CI, an App Store Connect API key — passed as appleApiKey and appleApiIssuer — is the better choice, because it does not expire on a password rotation and never triggers a two-factor prompt. Notarization itself is a scan on Apple's side that usually returns within minutes but has no guaranteed turnaround, so treat it as a step that can add time to a release rather than one you can schedule tightly around.

    Verify the signature before you ship

    Check the installer, not the executable inside it, and check it on a machine that has never seen your certificate. A build machine trusts things a user's laptop does not, so a signature that verifies locally can still fail in the wild — most often because an intermediate certificate was not embedded in the signature.

    Verify each platform's artefact
    # Windows — /pa uses the Authenticode policy a user's machine applies
    signtool verify /pa /v "dist\MyApp-Setup-1.4.0.exe"
    
    # macOS — the signature, then the notarization ticket
    codesign --verify --deep --strict --verbose=2 "dist/mac/MyApp.app"
    spctl --assess --type execute --verbose "dist/mac/MyApp.app"
    xcrun stapler validate "dist/MyApp-1.4.0.dmg"

    Gate the pipeline on the exit code rather than on the log text. Both tools return non-zero on failure, and both print output that is easy to misread — spctl in particular says accepted for an app that is signed but not stapled, which is exactly the case you are trying to catch. The signature verification guide goes through what each status value actually means.

    Common Electron signing errors

    Most Electron signing failures come from four causes: the certificate is not reachable, the configuration points at a key shape that no longer exists, the signature was applied but not to the file users download, or macOS was treated like a Windows problem. Match the symptom below.

    "No certificates were found that met all the given criteria"

    SignTool looked in the Windows store and found nothing matching. With a token, the device is unplugged or its middleware is not installed for the account running the build. With cloud signing, the session has expired — reconnect the desktop client and try again. It also appears when the build runs elevated or as a service account, because that switches to a different certificate store. Add /debug to a manual signtool sign to see what it considered.

    The build succeeds but the installer is unsigned

    Signing was skipped rather than failed. The usual reason is that no signing configuration resolved at all — an empty signtoolOptions, or a custom hook that swallowed its own error. electron-builder does not treat a missing certificate as fatal, so add the verification step from the previous section and let it fail the job.

    Users on the old release stopped receiving updates

    Almost always a publisher name change. electron-updater compares the publisher on the downloaded installer with the one recorded at install time, so a renewed certificate with a slightly different subject — a legal-form change, a new address line — breaks the match. Keep publisherName aligned with the CN, and when a subject really has to change, ship one release signed under the old name that carries users across.

    Notarization fails with "The signature does not include a secure timestamp"

    The macOS signing step ran without network access to Apple's timestamp service, usually behind a corporate proxy or on a locked-down runner. Signatures made offline are rejected at notarization. Allow outbound access to Apple's timestamp endpoint and re-sign; there is no configuration flag that makes it optional.

    "App is damaged and can't be opened" on another Mac

    Gatekeeper could not verify the app — commonly because the ticket was never stapled, so verification needs a network call the user's machine could not make, or because the disk image was rebuilt after notarization. Run xcrun stapler validate on the exact file you plan to publish, not on an earlier copy.

    SmartScreen still warns even though the installer is signed

    Expected for a new OV certificate. The signature is valid — the UAC prompt will show your organization name — but SmartScreen reputation builds with download volume and time. Re-signing does not accelerate it, and changing certificates resets it. The SmartScreen reputation guide explains what does and does not move it.

    Frequently Asked Questions

    Get instant answers to common questions about SSL certificates and our services.

    Still Have Questions?

    Our SSL experts are available 24/7 to help with any questions about certificates, installation, or technical issues.

    The bottom line

    Budget for two certificates if you ship two platforms, decide how the Windows one will authenticate before you buy it, and verify the installer rather than the executable. If the missing piece is the Windows certificate, My-SSL issues Certum OV and EV code signing certificates with the key held on a token or in Certum's cloud, and the document checklist shows what validation will ask your company for before issuance.