Project Setup

Code Signing and Notarizing Python CLI Binaries

Sign and notarize PyInstaller CLI binaries for macOS, sign them for Windows, automate both in CI with secrets kept safe, and verify the signatures users see.

Updated

A Python CLI distributed as a standalone binary meets the operating system's gatekeepers the moment a user downloads it. On macOS, an unsigned, un-notarized binary downloaded through a browser is blocked with a warning that it "cannot be opened because Apple cannot check it for malicious software" — most users stop there. On Windows, SmartScreen warns about unrecognised publishers, and corporate endpoint protection may quarantine unsigned executables outright. Code signing tells both systems who built the binary and that it has not changed since; Apple's notarization additionally records that Apple scanned it. This guide covers signing PyInstaller-built binaries for macOS and Windows, notarizing for macOS, doing both in CI without exposing certificates, and verifying the result. It belongs to the standalone binaries topic.

Prerequisites

  • Binaries built per platform, as in building cross-platform release binaries in CI.
  • For macOS: an Apple Developer Program membership and a Developer ID Application certificate, plus an app-specific password or App Store Connect API key for notarization.
  • For Windows: a code-signing certificate from a public CA (often on a hardware token or cloud HSM) or a cloud signing service.

What signing changes for users

Signing per platform What each operating system checks on a downloaded command line binary and how a publisher satisfies it. Signing per platform Platform Checks Publisher provides macOS signature + notarization Developer ID, notarytool Windows Authenticode, SmartScreen signtool + timestamp Linux nothing built in checksums, attestations Signing proves who built it and that it has not changed — not that it is safe.

Signing does not make software safe; it makes it attributable and tamper-evident. The operating system can show who published it, refuse it if a byte changed after signing, and — on macOS, after notarization — confirm Apple's automated scan found nothing. Linux has no equivalent gate for standalone binaries; there, checksums and attestations do the job.

Note what does not trigger these checks: installs through Homebrew, pipx, uv or package managers do not set the "downloaded from the internet" quarantine flag the same way a browser does. Signing matters most for the direct-download path — and for corporate machines where policy requires signed executables regardless of how they arrived.

The recipe: macOS

1. Sign with the hardened runtime

Notarization requires the hardened runtime and a secure timestamp. PyInstaller can sign during the build, which is the simplest path because it also signs the embedded libraries:

pyinstaller --onefile --name mytool \
    --codesign-identity "Developer ID Application: Acme Ltd (TEAMID1234)" \
    --osx-entitlements-file packaging/macos/entitlements.plist \
    src/mytool/__main__.py

An entitlements file is often needed because a frozen Python interpreter loads libraries that the hardened runtime would otherwise reject:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.security.cs.disable-library-validation</key>
    <true/>
</dict>
</plist>

Start with no entitlements, and add only what a failing run tells you it needs — every entitlement relaxes a protection. If you sign after building instead, use codesign --force --options runtime --timestamp --entitlements packaging/macos/entitlements.plist --sign "Developer ID Application: …" dist/mytool.

2. Notarize

Apple's notary service accepts a zip, disk image or installer package, not a bare binary:

ditto -c -k --keepParent dist/mytool mytool-macos.zip
xcrun notarytool submit mytool-macos.zip \
    --key "$ASC_KEY_PATH" --key-id "$ASC_KEY_ID" --issuer "$ASC_ISSUER_ID" \
    --wait

--wait blocks until Apple returns a verdict, typically within minutes. If it is rejected, xcrun notarytool log <submission-id> explains why — usually a missing timestamp, the hardened runtime not enabled, or an unsigned nested library.

A notarization ticket can be stapled to app bundles, disk images and installer packages, so they verify offline. A bare command-line binary cannot carry a stapled ticket; Gatekeeper checks Apple's servers on first run instead. If offline verification matters, ship the binary inside a signed, notarized and stapled .pkg installer.

From binary to notarized download macOS signing flow: PyInstaller signs with the hardened runtime, the binary is zipped, submitted to the notary service, and Gatekeeper checks it online on first run. From binary to notarized download Sign runtime + timestamp ditto zip notary input notarytool submit --wait First run Gatekeeper online upload accepted A bare binary cannot be stapled; wrap it in a .pkg for offline verification.

The recipe: Windows

Windows signing uses Authenticode through signtool, part of the Windows SDK:

signtool sign /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com `
    /n "Acme Ltd" dist\mytool.exe
signtool verify /pa /v dist\mytool.exe

/fd SHA256 sets the file digest, /tr and /td add an RFC 3161 timestamp so the signature stays valid after the certificate expires, and /n selects the certificate by subject name from the store. Since 2023, CA/Browser Forum rules require new code-signing keys to live in hardware — a USB token or a cloud HSM — so CI signing usually goes through a cloud service (Azure Trusted Signing, DigiCert KeyLocker, SSL.com eSigner and similar) that integrates with signtool or provides its own action. SmartScreen reputation builds over time with downloads of signed files; the warning fades as more users install your releases.

Signing in CI without leaking certificates

The certificate is the valuable secret. Keep signing in a dedicated job that runs only for tags, inside a protected environment, separate from the build — the same isolation pattern used for publishing to PyPI. On macOS runners, import the certificate into a temporary keychain that is deleted at the end of the job:

# scripts/macos-keychain.sh — runs only in the protected signing job
set -euo pipefail
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
echo "$DEVELOPER_ID_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$DEVELOPER_ID_P12_PASSWORD" \
    -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
rm "$RUNNER_TEMP/cert.p12"

Hosted runners are discarded after each job, but deleting the decoded certificate and using a throwaway keychain protects self-hosted runners too. Never print these variables, and never run the signing job on pull requests from forks.

UX considerations

  • Sign every release artefact, every time. A user who learned to bypass a warning once will do it again for a malicious download. Consistency is what makes the signature meaningful.
  • Use a stable publisher name. The certificate subject is what users and IT departments see; "Acme Ltd" should not become a contractor's personal name in the next release.
  • Document verification commands alongside checksums: codesign -dv --verbose=2 mytool and spctl on macOS, signtool verify /pa mytool.exe or the file's Properties dialog on Windows.
  • Plan for certificate expiry. Timestamped signatures remain valid, but you need the renewed certificate in CI before the old one expires; put the date in the team calendar.
Checking a signature Commands to verify a signed command line binary on macOS and Windows and what each confirms. Checking a signature Command Confirms codesign --verify --strict signature intact codesign -dv --verbose=4 authority, runtime, timestamp spctl -a -vvv -t install notarization accepted (pkg/zip) signtool verify /pa /v Authenticode chain and timestamp Run the checks in the pipeline right after signing, not after users report a warning.

Testing the behaviour

Verify signatures in the release pipeline right after signing, on the platform that produced them, so an unsigned or broken artefact never reaches the release page:

# tests/test_signatures.py
import os
import subprocess
import sys
from pathlib import Path

import pytest

BINARY = Path(os.environ.get("MYTOOL_BINARY", "dist/mytool"))
EXPECTED_AUTHORITY = "Developer ID Application: Acme Ltd"


@pytest.mark.skipif(sys.platform != "darwin" or not BINARY.exists(), reason="macOS build only")
def test_macos_signature_and_runtime():
    verify = subprocess.run(["codesign", "--verify", "--strict", "--verbose=2", str(BINARY)],
                            capture_output=True, text=True)
    assert verify.returncode == 0, verify.stderr
    info = subprocess.run(["codesign", "-dv", "--verbose=4", str(BINARY)],
                          capture_output=True, text=True).stderr
    assert f"Authority={EXPECTED_AUTHORITY}" in info
    assert "runtime" in info                      # hardened runtime flag
    assert "Timestamp=" in info


@pytest.mark.skipif(sys.platform != "win32" or not BINARY.with_suffix(".exe").exists(),
                    reason="Windows build only")
def test_windows_signature():
    result = subprocess.run(["signtool", "verify", "/pa", "/v", str(BINARY.with_suffix(".exe"))],
                            capture_output=True, text=True)
    assert result.returncode == 0, result.stdout

codesign -dv writes its details to stderr, which is why the test reads .stderr. For the user's-eye view on macOS, download the released zip through a browser on a clean machine (or a fresh VM), unzip it and run the binary: that exercises quarantine, Gatekeeper and the online notarization check together.

Conclusion

Signing and notarization turn a frightening warning into a binary that simply runs. On macOS, sign with the hardened runtime and a timestamp — PyInstaller's --codesign-identity does it during the build — then notarize a zip with notarytool --wait. On Windows, sign with signtool using a hardware-backed or cloud certificate and a timestamp. Keep certificates in a protected, tag-only CI job with a throwaway keychain, verify every artefact after signing, and tell users how to check the signature themselves.

Frequently asked questions

Do I need to sign if users install with Homebrew or pipx?

Usually not for Gatekeeper's sake: those installs do not go through the browser quarantine path. Signing still helps in managed environments with execution policies, and it costs little once the pipeline exists.

Can I use a self-signed certificate?

It produces a signature but no trust: neither Gatekeeper nor SmartScreen accepts it. It can be useful inside a company that distributes its own root certificate to managed machines.

Why does notarization fail with "The executable does not have the hardened runtime enabled"?

The binary was signed without --options runtime. With PyInstaller's built-in signing, the hardened runtime is enabled for you; if you re-sign afterwards, include --options runtime and --timestamp.

Does signing slow the binary down?

No measurable amount. The first launch after download may take a moment longer on macOS while Gatekeeper checks the notarization status online; later launches are unaffected.

What does signing cost?

The Apple Developer Program is an annual membership that covers Developer ID certificates and notarization. Windows code-signing certificates are sold by public certificate authorities, typically per year, with cloud signing services priced per month or per signature. For an open-source project, some foundations and sponsorship programmes provide signing for member projects — worth asking about before buying, since the recurring cost and the identity verification paperwork are the main hurdles for small teams.