Skip to content

How To: Code Sign Protected Apps

Protect your Python app with PyLocket, then sign it so end users can run it without security warnings on macOS and Windows.


Why Code Signing Matters

macOS — Gatekeeper

Unsigned apps trigger: "MyApp can't be opened because Apple cannot check it for malicious software." Users must bypass Gatekeeper manually (right-click → Open). 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)
  • EV (Extended Validation) certificate for immediate SmartScreen trust, or a standard certificate which builds reputation over time

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 (loadable code lands in the bundle's Contents/Frameworks/ where codesign expects it). PyLocket does not sign your app for you. PyLocket's embedded runtime library is itself Developer-ID-signed by Lamaute Labs, so when you notarize, add the com.apple.security.cs.disable-library-validation entitlement (see the macOS section below) or the app will refuse to load it under the hardened runtime.
  • Windows .exe: PyLocket does not re-sign; the protected .exe will trigger SmartScreen until you sign it.
  • In both cases: for trusted distribution, sign the protected artifact with your own certificate.

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
  6. Upload the signed artifact for distribution (PyLocket, App Store, your website)
  7. End users download and run the trusted, signed, protected app

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
          pyinstaller --onefile --windowed --name MyApp src/main.py

      - name: Submit to PyLocket
        env:
          PYLOCKET_TOKEN: ${{ secrets.PYLOCKET_TOKEN }}
        run: |
          pip install pylocket
          BUILD_ID=$(pylocket protect \
            --app ${{ secrets.PYLOCKET_APP_ID }} \
            --artifact dist/MyApp.app \
            --no-wait \
            --format json | jq -r '.build_id')
          echo "BUILD_ID=$BUILD_ID" >> $GITHUB_ENV

      - name: Wait for webhook (or poll)
        run: |
          # Option A: Your webhook receiver triggers this step
          # Option B: Poll as fallback
          pylocket status --build $BUILD_ID --wait

      - name: Download protected app
        run: pylocket fetch --build $BUILD_ID --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: required because PyLocket's embedded runtime library is
          # signed by a different team than yours. Without disable-library-validation,
          # the hardened runtime refuses to load it and 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 everything with your certificate under the hardened runtime
          codesign --deep --force --options runtime \
            --entitlements entitlements.plist \
            --sign "$CERT_NAME" MyApp.app

          # Verify
          codesign --verify --deep --strict MyApp.app
          spctl --assess --type exec MyApp.app

      - 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.zip

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

          # Staple the ticket
          xcrun stapler staple 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: Submit to PyLocket
        env:
          PYLOCKET_TOKEN: ${{ secrets.PYLOCKET_TOKEN }}
        run: |
          pip install pylocket
          $build = pylocket protect `
            --app ${{ secrets.PYLOCKET_APP_ID }} `
            --artifact dist/MyApp.exe `
            --no-wait --format json | ConvertFrom-Json
          echo "BUILD_ID=$($build.build_id)" >> $env:GITHUB_ENV

      - name: Wait and download
        run: |
          pylocket status --build $env:BUILD_ID --wait
          pylocket fetch --build $env:BUILD_ID --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 Immediate SmartScreen trust

Optional: Upload Signed App Back to PyLocket

After signing, you can replace the protected artifact in PyLocket with the signed version. This way, downloads from PyLocket's distribution system deliver the signed app:

# Get a presigned upload URL
UPLOAD_URL=$(curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"filename": "MyApp-signed.exe"}' \
  https://api.pylocket.com/v1/apps/$APP_ID/builds/$BUILD_ID/replace \
  | jq -r '.upload_url')

# Upload the signed artifact
curl -X PUT -T dist/protected/MyApp-signed.exe "$UPLOAD_URL"

# Confirm the replacement
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  https://api.pylocket.com/v1/apps/$APP_ID/builds/$BUILD_ID/replace/confirm

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 Use an EV certificate, or wait for reputation to build (standard cert)
"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