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
.exewill 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"]
- Your build pipeline creates the app (PyInstaller, cx_Freeze, Briefcase, etc.)
- Submit to PyLocket for protection:
pylocket protect --no-wait - PyLocket sends a webhook event when protection completes
- Your pipeline downloads the protected artifact
- Your pipeline signs it with your certificate
- Upload the signed artifact for distribution (PyLocket, App Store, your website)
- 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 (
.pfxfile) 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 |