Code Signing
Code signing attaches a cryptographic signature to a binary so an operating system can check its origin and its integrity before it agrees to run it.
Code signing is the practice of attaching a cryptographic signature to a binary so that the operating system can check where the binary came from and whether it has been modified before it agrees to run it. The signature is made with a private key whose certificate chains to a root the platform trusts, so a verifier can name the publisher and detect any edit to the signed bytes.
Every desktop and mobile platform now enforces some version of this check at install or at launch, which makes signing a required step in a release pipeline rather than an optional hardening pass.
Definition
A code signature has three parts: a digest of the signed contents, that digest encrypted with the signer's private key, and the certificate chain that lets a verifier connect the key back to a trusted root.
The digest is what gives integrity. Change a single byte of the binary after signing and the recomputed digest stops matching the one inside the signature, so the check fails without anyone needing to know what the original bytes were. The certificate chain is what gives origin. The certificate carries a subject name that a certificate authority validated before issuance, so the verifier learns which publisher signed rather than only that some key signed.
Signing is enforced by a different component on every platform, and the signature itself lives in a different place. The mechanism is the same underneath, so the table below is the practical map from one platform to another.
| Platform | Signing tool | Where the signature lives | What enforces it |
|---|---|---|---|
| macOS and iOS | codesign | Inside the Mach-O binary, or in _CodeSignature for a bundle | Gatekeeper at first launch and the kernel at load time |
| Windows | signtool, using Authenticode | The certificate table of the PE file | SmartScreen, installer policy, and driver load checks |
| Android | apksigner | The APK signing block | The package manager on install and on update |
| Java archives | jarsigner | META-INF entries inside the JAR | The runtime or the application that loads the archive |
| Container images and loose files | cosign, gpg | A detached signature stored beside the artifact or in the registry | A verification step in the deploy path |
What a verifier checks
Verification is a sequence of independent checks. Each one catches a different substitution, and a build can pass four of them and still be rejected on the fifth.
| Check | Failure it catches |
|---|---|
| The digest in the signature matches the artifact in hand | The bytes were edited after signing |
| The certificate chain terminates in a trusted root | A self-signed build presented as a release |
| The certificate was inside its validity window at signing time | An expired certificate reused for a fresh build |
| The certificate has not been revoked through CRL or OCSP | A key withdrawn after a compromise |
| The signer identity matches the one the policy expects | A valid signature from an unrelated publisher |
The third row is why timestamping matters. A countersignature from an RFC 3161 timestamp authority records the moment the signature was made, and a verifier reading that countersignature can accept a signature produced while the certificate was live even years after the certificate expired (RFC 3161, checked on 2026-08-13). Skip the timestamp and every shipped release becomes unverifiable on the certificate's expiry date.
Where the private key lives
The signature is only as trustworthy as the custody of the key that made it. Publicly trusted code signing certificates are subject to the CA/Browser Forum baseline requirements, which call for the private key to be generated and stored in a hardware crypto module rather than as a loose file on a workstation (CA/Browser Forum code signing requirements, checked on 2026-08-13). Teams that sign in an automated build reach that bar either with a network-attached signing service or with a key that is delivered to a job from an encrypted secret store and destroyed with the machine when the job ends.
A signature says nothing about the quality of the code. A binary signed with a stolen key verifies cleanly until the certificate is revoked, which is the argument for keeping signing keys out of developer laptops and out of every job that does not ship an artifact.
Example
The workflow below signs a macOS binary on a tag push. The signing identity arrives as a base64 encoded .p12 in a repository secret, gets imported into a keychain created for this job alone, signs the artifact, and is verified before the artifact is uploaded. Secrets are unavailable to workflow runs triggered from forks, so gating on tags keeps the signing job off pull request traffic (GitHub secrets documentation, checked on 2026-08-13).
name: release
on:
push:
tags: ["v*"]
jobs:
sign-macos:
runs-on: warp-macos-15-arm64-6x
steps:
- uses: actions/checkout@v4
- run: swift build -c release
- name: Import the signing identity into a temporary keychain
env:
SIGNING_CERT_P12: ${{ secrets.SIGNING_CERT_P12 }}
SIGNING_CERT_PASSWORD: ${{ secrets.SIGNING_CERT_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -base64 24)"
echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 3600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
echo "$SIGNING_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \
-P "$SIGNING_CERT_PASSWORD" -T /usr/bin/codesign
rm -f "$RUNNER_TEMP/cert.p12"
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
- name: Sign the binary and verify the signature
env:
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
run: |
codesign --force --options runtime --timestamp \
--keychain "$KEYCHAIN" \
--sign "$SIGNING_IDENTITY" \
.build/release/acme-cli
codesign --verify --strict --verbose=2 .build/release/acme-cli
- uses: actions/upload-artifact@v4
with:
name: acme-cli-macos
path: .build/release/acme-cli
- if: always()
run: security delete-keychain "$KEYCHAIN"Four lines carry the security properties of that job. security import with -T /usr/bin/codesign and the follow-up set-key-partition-list call pre-authorize codesign to use the imported key, which is what stops the headless machine from stalling on an authorization prompt no one can answer. --options runtime enables the hardened runtime that Apple requires before a build can be notarized. --timestamp asks Apple's timestamp service for the countersignature described above. The if: always() teardown removes the keychain even when the signing step fails.
Nothing in that job is specific to one runner fleet. Changing the value of runs-on moves the same steps onto a different macOS machine, which is covered step by step in the iOS code signing guide.
The Windows half of a cross-platform release follows the same shape with a different tool. signtool reads a PFX from the same kind of secret, and /tr points at the RFC 3161 timestamp authority published by the certificate authority that issued the certificate:
sign-windows:
runs-on: warp-windows-2025-x64-4x
needs: build-windows
steps:
- uses: actions/download-artifact@v4
with:
name: acme-cli-windows
- name: Sign with a timestamp and verify
shell: pwsh
env:
WINDOWS_CERT_PFX: ${{ secrets.WINDOWS_CERT_PFX }}
WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
TIMESTAMP_URL: ${{ vars.TIMESTAMP_URL }}
run: |
$pfx = "$env:RUNNER_TEMP\cert.pfx"
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERT_PFX))
signtool sign /fd SHA256 /td SHA256 /tr $env:TIMESTAMP_URL `
/f $pfx /p $env:WINDOWS_CERT_PASSWORD acme-cli.exe
Remove-Item $pfx
signtool verify /pa /v acme-cli.exe/fd SHA256 sets the digest algorithm for the file, /td SHA256 sets it for the timestamp, and signtool verify /pa /v re-reads the finished executable using the same policy a Windows client applies. Running the verify command in the job turns a silent signing failure into a red step, which is worth the two seconds it costs.
Related Terms
- Code signing iOS builds on GitHub Actions: the keychain, certificate, and profile sequence for an Xcode archive job, with the errors each step throws.
- What a provisioning profile binds together: the Apple file that ties an application identifier, a signing certificate, and a distribution method into one document.
- Signing Windows binaries in GitHub Actions: certificate delivery,
signtoolinvocation, and timestamping on a Windows job. - WarpBuild security documentation: runner isolation, encrypted storage volumes, and how build secrets are handled.
- Cloud runners documentation: the macOS and Windows runner labels available for a signing job, with sizes and images.
- WarpBuild pricing: per minute rates by runner type.
FAQ
What does a code signature actually prove?
It proves two things. The signature was produced by the holder of a private key whose certificate chains to a root the verifying platform trusts, and the signed bytes have not changed since that signature was made. Whether the code behaves well is a separate question that signing leaves to tests, review, and scanners.
Why does a signature need a timestamp?
A signing certificate has a validity window, usually one to three years. Without a timestamp, every binary signed with that certificate starts failing verification the day the certificate expires. A countersignature from an RFC 3161 timestamp authority records when the signing happened, so a verifier can accept a signature made while the certificate was still valid.
Is code signing the same as notarization?
No. Signing is a local operation that attaches a signature using a key you hold. Notarization on Apple platforms is a separate submission to Apple's service, which scans the already signed build and returns a ticket. A build can be signed and still be refused by Gatekeeper if it was never notarized.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.