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
.applaid out so you can sign and notarize it with your own Apple Developer ID. Top-level code (Mach-O files,.dyliband.sofiles,*.frameworkbundles) stays inContents/Frameworks/; your Python packages, including the.sofiles inside them, are inContents/Resources/. Sign it inside-out: the nested binaries first, then the bundle.codesign --deepalone does not descend intoContents/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.applaunches 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 thecom.apple.security.cs.disable-library-validationentitlement (the macOS example below does), or the app crashes on its first protected call. - Windows .exe: PyLocket does not re-sign; the protected
.exewill 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"]
- 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 (and notarizes it on macOS)
- Upload the signed artifact for distribution: back to PyLocket with Replace with Signed Package, or to your own website or store
- 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 (
.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: 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:
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:
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 |