Code Signing and Notarization for Cross-Platform Desktop Apps
A practical guide to Apple code signing, notarization, and Azure Trusted Signing for cross-platform desktop apps — from certificate setup to CI/CD automation with GitHub Actions.
Shipping desktop software sounds straightforward until your users cannot open your app.
On macOS, unsigned binaries get blocked by Gatekeeper. On Windows, SmartScreen warnings reduce user trust and install rates before anyone even tries your software. Code signing and notarization are not optional. But the setup is far more complex than it should be.
We have implemented this across multiple cross-platform apps and built a repeatable CI/CD pipeline that removes most of the friction. If this is misconfigured, your release pipeline breaks, or worse, your users lose trust in your software.
Most teams underestimate this step until their first release fails in CI or gets blocked on user machines. Getting this right is the difference between a smooth release and users abandoning your install.
This is the guide we wish we had when we started.
Who This Is For
- Teams shipping cross-platform desktop apps (Tauri, Electron, etc.)
- Developers setting up CI/CD pipelines for signed builds
- Companies distributing software outside of app stores
Why This Is Harder Than It Should Be
- Apple’s tooling is poorly documented in key areas
- Error messages are vague or misleading
- CI/CD environments behave differently than local machines
- Cross-platform signing adds multiple layers of complexity
Most of these issues do not show up until CI or user installs, when fixes are slower and more expensive.
What Signing and Notarization Actually Do
Code signing cryptographically proves that your app was built by you and has not been tampered with since. On macOS, this requires an Apple Developer ID certificate. On Windows, you need a code signing certificate from a trusted certificate authority.
Notarization is an Apple-specific step where you submit your signed app to Apple’s servers for automated malware scanning. If it passes, Apple issues a “ticket” that can be stapled to the app or fetched by Gatekeeper at runtime. This is not App Store review. It is an automated scan that typically completes within minutes, though delays can occur.
Without both, your macOS app will be blocked by default and require users to manually override Gatekeeper in System Settings. Most users will not do this.
macOS: Certificate Setup
You need a Developer ID Application certificate from your Apple Developer account. Here is the CI/CD-friendly setup:
- Generate the certificate in Apple Developer portal
- Export it as a
.p12file with a password - Base64-encode it for storage as a GitHub secret:
base64 -i certificate.p12 | pbcopy
In your GitHub Actions workflow, import the certificate into a temporary keychain:
# Create and configure a temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Import the certificate
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
security import certificate.p12 \
-P "$APPLE_CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 \
-k "$KEYCHAIN_PATH"
# Grant access to signing tools
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
The -lut 21600 sets a 6-hour timeout, which is generous enough for most builds. The set-key-partition-list step is critical. Without it, the signing tools cannot access the certificate from the keychain.
macOS: Notarization with App Store Connect API
Apple supports notarization via the App Store Connect API using a .p8 key file. You need three values from your Apple Developer account:
- API Issuer ID — found in App Store Connect under Users and Access → Keys
- API Key ID — the identifier of your key
- API Key file — the
.p8private key file
The key gotcha we spent hours debugging: the APPLE_API_KEY_PATH environment variable must be the full path to the .p8 file, not the directory containing it. This is poorly documented and the error messages give no indication of the actual problem.
# Write the API key to disk
mkdir -p ~/private_keys
echo "$APPLE_API_KEY" > ~/private_keys/AuthKey_${APPLE_API_KEY_ID}.p8
# Set the FULL FILE PATH (not directory!)
export APPLE_API_KEY_PATH=~/private_keys/AuthKey_${APPLE_API_KEY_ID}.p8
If you are using Tauri, it picks up the signing and notarization environment variables automatically during tauri build. No additional configuration in tauri.conf.json is needed. Set the environment variables and Tauri handles the rest.
Windows: Azure Trusted Signing
Microsoft’s Azure Trusted Signing eliminates the need to manage your own code signing certificate. The certificate lives in Azure’s HSM, and you sign binaries via an API call.
There are two approaches for CI/CD:
Approach 1: dotnet sign via Tauri’s signCommand (recommended)
This signs binaries during the Tauri build — both the .exe binary and the NSIS installer get signed automatically. Install the tool and configure it as a signCommand:
# Install the dotnet sign tool
dotnet tool install -g --prerelease sign
# Resolve the full path (forward slashes avoid Tauri parsing issues)
$signExe = (Get-Command sign).Source -replace '\\', '/'
# Create a Tauri config override with the signCommand
$signCmd = "$signExe code artifact-signing --timestamp-url http://timestamp.acs.microsoft.com --artifact-signing-endpoint $env:AZURE_SIGNING_ENDPOINT --artifact-signing-account $env:AZURE_SIGNING_ACCOUNT --artifact-signing-certificate-profile $env:AZURE_SIGNING_PROFILE %1"
The %1 is replaced by Tauri with the file path to sign. Pass this config via --config signing-config.json when building. The Azure credentials authenticate via AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET environment variables.
Important: The NuGet package is called
sign, notAzure.CodeSigning(which was deprecated). The--prereleaseflag is required as of early 2026.
Approach 2: azure/trusted-signing-action (post-build only)
This GitHub Action signs files after the build completes. It only signs what you point it at — typically the installer, not the binary inside it:
- name: Sign Windows installer
uses: azure/trusted-signing-action@v0.5.0
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
certificate-profile-name: ${{ secrets.AZURE_SIGNING_PROFILE }}
files-folder: target/release/bundle/nsis
files-folder-filter: exe
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
We recommend Approach 1 because it signs both the binary and the installer. With Approach 2, the installed executable will not show a digital signature in its Properties dialog — only the installer will.
The RFC3161 timestamp ensures the signature remains valid after the certificate expires.
Cross-Platform CI/CD with Tauri
For a Tauri v2 app targeting Windows, macOS (both Intel and Apple Silicon), and Linux, your build matrix looks like this:
strategy:
matrix:
include:
- platform: windows-latest
target: x86_64-pc-windows-msvc
- platform: macos-latest
target: aarch64-apple-darwin
- platform: macos-latest
target: x86_64-apple-darwin
- platform: ubuntu-latest
target: x86_64-unknown-linux-gnu
Each platform runs its respective signing steps after the build. macOS builds sign and notarize via Apple’s toolchain, Windows builds sign via Azure Trusted Signing, and Linux builds are typically distributed unsigned outside of package manager ecosystems.
Auto-Updates with Signed Binaries
If your app includes an auto-update mechanism, the update binary must also be signed. Our approach:
- Build and sign the new binary in CI
- Upload to a distribution endpoint (we use Cloudflare R2)
- Generate a SHA256 checksum alongside the binary
- The app checks a
version.txtfile periodically (every 6 hours) - If a new version is available, download the binary, verify the checksum, and replace the running binary
On Windows, the running binary cannot be replaced while the process is active. The workaround is to rename the current binary to .old, write the new binary in its place, then exit. The next launch uses the new binary and cleans up the .old file.
On Unix systems, atomic rename handles this cleanly. The running process keeps its file descriptor to the old binary, and the new binary is picked up on next launch.
Secrets You Need
Here is the complete list of GitHub Actions secrets for a cross-platform signed build:
macOS:
APPLE_CERTIFICATE— base64-encoded .p12APPLE_CERTIFICATE_PASSWORD— .p12 passwordAPPLE_SIGNING_IDENTITY— e.g., “Developer ID Application: Your Company”APPLE_API_ISSUER— App Store Connect Issuer IDAPPLE_API_KEY_ID— API Key identifierAPPLE_API_KEY— .p8 key contents (raw text, not base64)APPLE_TEAM_ID— Developer Team ID
Windows (Azure Trusted Signing):
AZURE_TENANT_IDAZURE_CLIENT_IDAZURE_CLIENT_SECRETAZURE_SIGNING_ENDPOINTAZURE_SIGNING_ACCOUNTAZURE_SIGNING_PROFILE
Lessons Learned
Apple’s error messages are often too vague to diagnose issues directly. When notarization fails, you typically get a generic error. You need to inspect the notarization log (available via xcrun notarytool log) to understand what actually failed.
Test on a clean machine. Your development machine has the certificate in its keychain, so signing issues will not surface locally. Always test the CI-built artifact on a machine that has never seen your developer certificate.
Budget time for the first setup. Our first cross-platform signed build took a full day to get right, mostly due to Apple’s API key path requirements and Azure’s service principal configuration. Subsequent projects reuse the same workflow and take minutes.
Timestamp your signatures. Without a timestamp, your signature becomes invalid when the certificate expires. With a timestamp, the signature remains valid indefinitely because it proves the binary was signed while the certificate was still valid.
If you are setting up code signing and notarization for the first time, we can help you avoid the trial-and-error that slows down most teams. We have built repeatable pipelines for cross-platform apps that take hours instead of days to configure. Reach out if you want to get this right the first time.