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.

  • macOS .app bundles: PyLocket re-applies an ad-hoc signature (sufficient to launch locally, but NOT for distribution)
  • Windows .exe: PyLocket does not re-sign — the protected .exe will trigger SmartScreen
  • 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",
    "artifact_type": "pyinstaller_onefile",
    "platform": "darwin-arm64",
    "func_count": 689,
    "protected_size_bytes": 52974581,
    "download_path": "/v1/builds/550e8400-e29b-41d4-a716-446655440000/download"
  }
}

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 ad-hoc signatures
          find MyApp.app -name "_CodeSignature" -exec rm -rf {} + 2>/dev/null || true

          # Sign everything with your certificate
          codesign --deep --force --options runtime \
            --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