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.

PlatformSigning toolWhere the signature livesWhat enforces it
macOS and iOScodesignInside the Mach-O binary, or in _CodeSignature for a bundleGatekeeper at first launch and the kernel at load time
Windowssigntool, using AuthenticodeThe certificate table of the PE fileSmartScreen, installer policy, and driver load checks
AndroidapksignerThe APK signing blockThe package manager on install and on update
Java archivesjarsignerMETA-INF entries inside the JARThe runtime or the application that loads the archive
Container images and loose filescosign, gpgA detached signature stored beside the artifact or in the registryA 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.

CheckFailure it catches
The digest in the signature matches the artifact in handThe bytes were edited after signing
The certificate chain terminates in a trusted rootA self-signed build presented as a release
The certificate was inside its validity window at signing timeAn expired certificate reused for a fresh build
The certificate has not been revoked through CRL or OCSPA key withdrawn after a compromise
The signer identity matches the one the policy expectsA 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.

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.