Skip to content

How To: Code Sign Protected Apps

PyLocket does not code-sign your app. You sign the protected build with your own certificate (on macOS you also notarize it) and upload the signed copy back to PyLocket, which then delivers that copy to your customers. This page shows how.

An app you have not signed meets a Windows SmartScreen warning or a macOS Gatekeeper block when your customers open it, and the PyLocket Installer and Hub refuse to install it on those platforms.


Why Code Signing Matters

macOS — Gatekeeper

Unsigned apps trigger: "MyApp can't be opened because Apple cannot check it for malicious software." On macOS 15 and later, users must open System Settings → Privacy & Security and click Open Anyway for your app (the older right-click → Open shortcut no longer bypasses Gatekeeper). For professional distribution, you need:

  • Apple Developer ID certificate ($99/year via developer.apple.com)
  • Notarization — Apple scans your app and issues a ticket that Gatekeeper accepts

Windows — SmartScreen

Unsigned executables trigger: "Windows protected your PC — Microsoft Defender SmartScreen prevented an unrecognized app from starting." For trusted distribution, you need:

  • Code signing certificate from a trusted CA (DigiCert, Sectigo, GlobalSign)
  • An EV (Extended Validation) or a standard certificate. Since 2024 neither gives a new release immediate SmartScreen trust: SmartScreen shows its prompt until the file has built up download reputation, whatever the certificate type. Signing still names you as the publisher in that prompt

How PyLocket Affects Signatures

PyLocket protection modifies your artifact: it adds the encrypted payload, runtime binary, and bootstrap loader. This invalidates any existing code signature, so you re-sign the protected output with your own certificate before distribution.

  • macOS .app bundles: PyLocket produces a protected .app laid out so you can sign and notarize it with your own Apple Developer ID. Top-level code (Mach-O files, .dylib and .so files, *.framework bundles) stays in Contents/Frameworks/; your Python packages, including the .so files inside them, are in Contents/Resources/. Sign it inside-out: the nested binaries first, then the bundle. codesign --deep alone does not descend into Contents/Resources/, so it leaves those binaries without your signature, and notarization rejects them. The macOS example below signs inside-out. PyLocket applies only an ad-hoc signature (no identity) so the protected .app launches on Apple Silicon; it does not sign it with a Developer ID or notarize it for you.
  • macOS runtime library and library validation: PyLocket's embedded runtime library (_pylocket_rt.so) is Developer-ID-signed by Lamaute Labs, not by you. Under the hardened runtime, library validation refuses to load a library signed by a different Team. When you sign your protected app under the hardened runtime, always add the com.apple.security.cs.disable-library-validation entitlement (the macOS example below does), or the app crashes on its first protected call.
  • Windows .exe: PyLocket does not re-sign; the protected .exe will trigger SmartScreen until you sign it.
  • In both cases: sign the protected artifact with your own certificate, then upload the signed copy to PyLocket (below) so that is what your customers download.

End-to-End Pipeline

flowchart LR
    A["1. BUILD\n(your CI)"] --> B["2. PROTECT\n(PyLocket)"]
    B --> C["3. WEBHOOK\n(event)"]
    C --> D["4. DOWNLOAD\n+ 5. SIGN"]
    D --> E["6. DISTRIB\n(upload)"]
    E --> F["7. END USER\nruns app"]
  1. Your build pipeline creates the app (PyInstaller, cx_Freeze, Briefcase, etc.)
  2. Submit to PyLocket for protection: pylocket protect --no-wait
  3. PyLocket sends a webhook event when protection completes
  4. Your pipeline downloads the protected artifact
  5. Your pipeline signs it with your certificate (and notarizes it on macOS)
  6. Upload the signed artifact for distribution: back to PyLocket with Replace with Signed Package, or to your own website or store
  7. End users download and run the protected app with your signature

Configure Webhooks

Instead of polling for build completion (which doesn't scale for long queues), configure a webhook to be notified instantly:

# Set up a webhook URL for your app
pylocket webhook set --app <APP_ID> --url https://your-server.com/pylocket-webhook

# The command returns a secret — save it immediately!
# Secret: pyls_abc123...

# Test the webhook
pylocket webhook test --app <APP_ID>

# Check delivery history
pylocket webhook status --app <APP_ID>

Webhook Payload

When a build completes, PyLocket POSTs a JSON payload:

{
  "event": "build.completed",
  "timestamp": "2026-04-04T12:00:00Z",
  "build": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "app_id": "15ed6311-6ef1-436e-8476-3bc287e44dfb",
    "version": "2.1.0",
    "status": "ready",
    "platform": "darwin-arm64",
    "protected_size_bytes": 52974581,
    "download_path": "/v1/builds/550e8400-e29b-41d4-a716-446655440000/download"
  }
}

The fields you need to react to a completed build are id, status, and download_path (fetch the protected artifact from that path). Additional metadata fields may be present in the payload; treat the schema as additive and ignore fields you don't use.

Verify Webhook Signatures

Every webhook includes an X-PyLocket-Signature header:

X-PyLocket-Signature: t=1712188800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9

Verify it in your receiver:

import hmac, hashlib, json

def verify_signature(payload_body: bytes, signature_header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    timestamp = parts["t"]
    expected = parts["v1"]
    signed_content = f"{timestamp}.{payload_body.decode()}"
    computed = hmac.new(secret.encode(), signed_content.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(computed, expected)

macOS: Sign with Apple Developer ID

Prerequisites

  • Apple Developer account ($99/year)
  • Developer ID Application certificate installed in Keychain
  • Xcode command-line tools (xcode-select --install)

GitHub Actions Example

name: Build, Protect, Sign (macOS)

on:
  push:
    tags: ['v*']

jobs:
  build-protect-sign:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Build app
        run: |
          pip install pyinstaller
          # A macOS .app must be built with --onedir (PyLocket refuses a
          # --onefile .app)
          pyinstaller --onedir --windowed --name MyApp src/main.py

      - name: Protect with PyLocket
        env:
          PYLOCKET_TOKEN: ${{ secrets.PYLOCKET_TOKEN }}
        run: |
          pip install pylocket
          # protect waits for completion by default; --out downloads the
          # protected artifact when the build is ready
          pylocket protect \
            --app ${{ secrets.PYLOCKET_APP_ID }} \
            --artifact dist/MyApp.app \
            --platform macos-arm64 \
            --out dist/protected/

      - name: Sign with Developer ID
        env:
          CERT_NAME: "Developer ID Application: Your Name (TEAMID)"
        run: |
          # Unzip the protected .app
          cd dist/protected && unzip -q *.app.zip

          # Remove stale signatures
          find MyApp.app -name "_CodeSignature" -exec rm -rf {} + 2>/dev/null || true

          # Entitlements. PyLocket's embedded runtime library (_pylocket_rt.so) is
          # signed by Lamaute Labs, not by you. Under the hardened runtime,
          # library validation refuses a library from another Team unless the app
          # has disable-library-validation, so always keep this entitlement.
          # Without it, the app crashes on first use.
          cat > entitlements.plist <<'EOF'
          <?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>
          EOF

          # Sign inside-out under the hardened runtime: nested binaries first,
          # then the bundle.
          #
          # 1. Every Mach-O under Contents/Resources: the .so files inside your
          #    Python packages, and any other binary there. codesign --deep does
          #    not descend into Contents/Resources, and notarization rejects a
          #    binary there that has no Developer ID signature.
          find MyApp.app/Contents/Resources -type f -print0 |
            while IFS= read -r -d '' f; do
              if file -b "$f" | grep -q 'Mach-O'; then
                codesign --force --options runtime --timestamp \
                  --sign "$CERT_NAME" "$f"
              fi
            done

          # 2. The bundle. --deep signs the code under Contents/Frameworks and
          #    Contents/MacOS.
          codesign --deep --force --options runtime --timestamp \
            --entitlements entitlements.plist \
            --sign "$CERT_NAME" MyApp.app

          # If codesign stops with "too many levels of symbolic links", the bundle
          # has a symbolic-link loop and the files behind it are missing. A build
          # protected by an engine older than 1.0.0.0.0.180 can have one in a
          # package that mixes code and data: upload your app again so the
          # current engine protects it. Never delete the symlinks or the
          # *.dist-info folders to get past an error: they are load-bearing
          # (plugin loaders register through them) and PyLocket rejects a
          # signed re-upload that lost them.

          # Verify the signature (Gatekeeper's spctl check comes after
          # notarization, below)
          codesign --verify --deep --strict MyApp.app

          # Confirm your signing step preserved the package metadata (must list
          # the same paths as before signing; empty output here means a tool
          # stripped them and the re-upload will be rejected)
          find MyApp.app -name entry_points.txt

      - name: Notarize
        env:
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
          TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
        run: |
          # Create ZIP for notarization
          ditto -c -k --keepParent dist/protected/MyApp.app MyApp.app.zip

          # Submit and wait
          xcrun notarytool submit MyApp.app.zip \
            --apple-id "$APPLE_ID" \
            --password "$APP_PASSWORD" \
            --team-id "$TEAM_ID" \
            --wait

          # Staple the ticket
          xcrun stapler staple dist/protected/MyApp.app

          # Gatekeeper accepts the app once it is notarized and stapled
          spctl --assess --type exec dist/protected/MyApp.app

Windows: Sign with Authenticode

Prerequisites

  • Code signing certificate (.pfx file) from a trusted CA
  • Windows SDK (signtool.exe)

GitHub Actions Example

name: Build, Protect, Sign (Windows)

on:
  push:
    tags: ['v*']

jobs:
  build-protect-sign:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build exe
        run: |
          pip install pyinstaller
          pyinstaller --onefile --name MyApp src/main.py

      - name: Protect with PyLocket
        env:
          PYLOCKET_TOKEN: ${{ secrets.PYLOCKET_TOKEN }}
        run: |
          pip install pylocket
          # protect waits for completion by default; --out downloads the
          # protected artifact when the build is ready
          pylocket protect `
            --app ${{ secrets.PYLOCKET_APP_ID }} `
            --artifact dist/MyApp.exe `
            --platform win-x64 `
            --out dist/protected/

      - name: Sign with Authenticode
        env:
          CERT_PASSWORD: ${{ secrets.CODE_SIGNING_PASSWORD }}
        run: |
          # Decode certificate from base64 secret
          $certBytes = [Convert]::FromBase64String("${{ secrets.CODE_SIGNING_CERT_B64 }}")
          [IO.File]::WriteAllBytes("cert.pfx", $certBytes)

          # Sign with SHA-256 + timestamp
          & "C:\Program Files (x86)\Windows Kits\10\bin\x64\signtool.exe" sign `
            /f cert.pfx /p "$env:CERT_PASSWORD" `
            /tr http://timestamp.digicert.com /td sha256 /fd sha256 `
            dist/protected/MyApp.exe

          # Verify
          & "C:\Program Files (x86)\Windows Kits\10\bin\x64\signtool.exe" verify `
            /pa dist/protected/MyApp.exe

          # Clean up
          Remove-Item cert.pfx

Certificate Types Reference

Platform Certificate Typical Cost Trust Level
macOS Apple Developer ID $99/year Trusted after notarization
Windows Standard code signing $200-400/year Builds SmartScreen reputation over time
Windows EV code signing (HSM) $400-700/year Builds SmartScreen reputation over time (no longer immediate)

Upload the Signed App Back to PyLocket

After signing, replace the protected artifact in PyLocket with your signed copy. PyLocket stores it byte for byte, so your signature (and your notarization, if you notarized it) stays intact, and downloads, push updates and the PyLocket Installer and Hub deliver your signed app. Until you do, downloads and push updates deliver the protected build without your signature, and the Installer and Hub refuse it on Windows and macOS. The build must be ready. You can also do this in the Developer Portal; see Replace with Signed Package.

Replace with Signed Package takes a macOS .app (including Briefcase) or a single-file Windows .exe. A Windows build uploaded as a folder (PyInstaller onedir, cx_Freeze) cannot be replaced with a signed copy yet, so the PyLocket Installer and Hub cannot install it.

In the default delivery mode, each newly protected build is served as soon as it is ready, before you have signed it, and it takes over from the signed copy of the previous build. To serve only signed copies, switch the app to serve released builds only and release each build after you upload its signed copy (see If your app serves only released builds).

The simplest way to upload is the CLI, which runs all three steps below and archives a .app with its symlinks for you:

pylocket replace-signed <BUILD_ID> <SIGNED_PATH>

To call the API directly, save these steps as a script and run it with bash (not pasted into a terminal: set -euo pipefail stops the script at the first failed step, so step 3 never re-checks an older upload, but it would also close an interactive shell). The script reads TOKEN (your access token), APP_ID and BUILD_ID from its environment, so export them first:

export TOKEN=<ACCESS_TOKEN> APP_ID=<APP_ID> BUILD_ID=<BUILD_ID>
set -euo pipefail

# The signed file: the signed .exe, or for macOS the signed .app archived with
#   ditto -c -k --keepParent MyApp.app MyApp.app.zip
SIGNED=dist/protected/MyApp.exe
SIZE=$(wc -c < "$SIGNED" | tr -d ' ')
SHA256=$(shasum -a 256 "$SIGNED" | cut -d ' ' -f 1)   # or: sha256sum

# 1. Get a presigned upload URL. The body gives the file's size in bytes and
#    its SHA-256 (64 hex characters); both fields are required. If the request
#    is refused (for example, the build is not ready), jq stops and prints
#    the response, whose "error" says why.
UPLOAD_URL=$(curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"file_size_bytes\": $SIZE, \"file_hash_sha256\": \"$SHA256\"}" \
  https://api.pylocket.com/v1/apps/$APP_ID/builds/$BUILD_ID/replace \
  | jq -r '.upload_url // error("no upload URL: \(.)")')

# 2. Upload the signed file. The URL is signed for this Content-Type, so send
#    exactly this header. -f makes curl fail on an HTTP error; -S prints it.
curl -sSf -X PUT -H "Content-Type: application/octet-stream" \
  --upload-file "$SIGNED" "$UPLOAD_URL"

# 3. Confirm. PyLocket checks that the upload is a signed copy of this
#    protected build. This prints the response and then its HTTP status.
curl -sS -X POST -w '\nHTTP status: %{http_code}\n' \
  -H "Authorization: Bearer $TOKEN" \
  https://api.pylocket.com/v1/apps/$APP_ID/builds/$BUILD_ID/replace/confirm

Read success in the confirm's response, not only the HTTP status. When PyLocket refuses the uploaded file, the answer is HTTP 200 with "success": false. The one exception is a file that VirusTotal already knows as malicious, which gets 403.

Confirm response What it means
200, "success": true The build's protected artifact is now your signed file. message notes it if the download cache could not be refreshed.
200, "success": false The file was refused and nothing was replaced. message says what to fix: for example, the upload is not a zip, its symlinks were flattened, it is not signed, or it is not a signed copy of this build. Fix the file and run all three steps again.
400 This build's type can't be replaced.
403 VirusTotal already knows the signed file as malicious, and nothing was replaced. A suspended account also gets 403.
404 The app, the build or the uploaded file wasn't found. Run step 2 before step 3.
409 The build isn't ready.

An error status comes with a JSON body whose error.message says why.


Troubleshooting

macOS

Issue Solution
"unidentified developer" Sign with Developer ID and notarize
"damaged and can't be opened" Run xattr -cr MyApp.app then re-sign
Notarization fails Check xcrun notarytool log <submission-id> for details
spctl rejects after signing Ensure --options runtime flag was used

Windows

Issue Solution
SmartScreen warning persists Expected for a new release, with an EV or a standard certificate: the prompt goes away once the file has built up download reputation. Check that the prompt names you as the publisher
"Signature not valid" Ensure timestamp server was specified (/tr)
signtool not found Install Windows SDK or use dotnet tool install sign
Certificate expired Timestamped signatures remain valid indefinitely