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
.applaid out so you can sign and notarize it with your own Apple Developer ID (loadable code lands in the bundle'sContents/Frameworks/wherecodesignexpects 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 thecom.apple.security.cs.disable-library-validationentitlement (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
.exewill 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"]
- 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",
"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 (
.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 |