Back to glossary

Code Signing

Code signing attaches a cryptographic signature to software so users can verify who released it and that the bytes are unchanged, not that it is safe.

Also known as software signing, package signing, artifact signing, digital signature, code signing certificate, PGP signature, GPG signature, Sigstore signing, keyless signing, registry signatures, Authenticode

Code signing is the practice of hashing software and signing the hash with a private key, so anyone holding the matching public key can confirm the bytes are unchanged and see which identity signed them. Also called software, package, or artifact signing, it proves who released an artifact. It does not prove the artifact is safe.

The cryptography is the easy part. The hard part is knowing which public key should have signed, and every code signing system is an answer to that one question. A certificate authority vouches for a company's legal name. An OpenPGP key is trusted because someone pinned its fingerprint. Sigstore issues a certificate that lives for ten minutes and names an email address or a CI workflow. In all three, a valid signature binds bytes to an identity and nothing more. In the xz utils backdoor, Arch Linux's build pinned the release key of Jia Tan, the attacker, and the backdoored 5.6.1 tarball verified. Our position: sign everything, verify against an identity you chose in advance, and treat a valid signature as evidence about who released the code, never about what the code does. That second question is only answered by reading the package.

What is code signing?

#

Code signing is a digital signature applied to a software artifact: an executable, a package tarball, a container image, a Git tag. The signer computes a cryptographic hash of the artifact and signs the hash with a private key. The verifier recomputes the hash and checks the signature with the public key. A match gives two guarantees. Integrity: the bytes are the ones that were signed. Attribution: the holder of that private key signed them.

A hash alone gives integrity without attribution. An integrity field in a lockfile tells you the bytes match an expected digest, but anyone can compute a digest of anything. A signature adds a claim that a particular key holder approved these exact bytes.

Real signature formats carry more than the signature. They bundle the digest algorithm, the signer's certificate or key ID, and often a trusted timestamp. Timestamps exist because certificates expire. An RFC 3161 timestamp proves a signature was made while the certificate was valid, so the signature keeps verifying after expiry. nuget.org requires one on every author-signed package for that reason.

How does code signing work?

#

Code signing works by binding a public key to an identity, then checking each signature against the key bound to the identity you expect. The signing math is the same everywhere. What differs is how a verifier learns which key belongs to whom. There are three answers in wide use, plus a fourth kind of signature that registries add on their own.

In the X.509 model used by Windows Authenticode, Apple Developer ID, Java JAR signing, and NuGet author signatures, a publisher buys a code signing certificate from a certificate authority (CA). The CA checks the publisher's identity and signs a certificate binding the publisher's public key to an organization name. Operating systems ship a store of trusted root CAs, so the verifier walks the chain from the publisher's certificate to a root it already trusts. nuget.org enforces this strictly: author certificates must chain to a root trusted by default on Windows, and packages signed with self-issued certificates are rejected.

The CA model concentrates risk in a long-lived private key. Under CA/Browser Forum Ballot CSC-31, code signing certificates are capped at 460 days from March 1, 2026, down from 39 months. Shorter certificates shrink the window in which a stolen key is useful. They do not close it, and revoking a certificate early can push every user onto a re-signed build. OpenAI paid that cost after the Axios compromise: malicious axios 1.14.1 ran inside OpenAI's macOS app-signing workflow on March 31, 2026. OpenAI found no evidence the certificate was taken, revoked and rotated it anyway, and told users older app versions may stop working after May 8, 2026.

OpenPGP: trust whoever you pinned

#

OpenPGP, usually through GnuPG (gpg), has no central authority. Anyone generates a key pair, publishes the public key to a keyserver, and ships detached .asc signatures next to releases. The verifier must decide out of band which key fingerprint is the right one. Maven Central still requires a PGP signature for every uploaded file, with the public key available from a keyserver.

That out-of-band step is where OpenPGP fails in package registries. An index cannot check a signature when it does not know whose key should have made it. PEP 740, the standard behind PyPI's replacement, puts it in one line: PyPI "has no mechanism for identifying the right public key for the signature." When PyPI audited three years of uploads in May 2023, it found about 50,000 signatures from 1,069 unique keys, and only 36% of those keys could be meaningfully verified. PyPI began ignoring PGP signatures on new uploads that month, as we covered when PyPI launched attestations. npm deprecated its PGP registry signatures on April 25, 2023. CPython stops publishing PGP signatures for its own releases starting with Python 3.14, under PEP 761.

Done carefully, OpenPGP pinning looks like a Linux distribution's build recipe. Arch Linux's xz package, as of its 5.6.1 update on March 9, 2024, listed exactly two acceptable fingerprints:

Bash
validpgpkeys=('3690C240CE51B4670D30AD1C38EE757D69184620'  # Lasse Collin
              '22D465F2B4C173803B20C6DE59FCF207FEA7F445') # Jia Tan
source=("https://github.com/tukaani-project/xz/releases/download/v${pkgver}/xz-${pkgver}.tar.gz"{,.sig})

That is correct pinning, and it did exactly what it promised. The build refused any tarball not signed by one of the two keys. The 5.6.0 and 5.6.1 tarballs "were created and signed by Jia Tan," according to Lasse Collin, and the code that triggered the backdoor "was never included in the Git repository." The signature was valid, the identity was the one Arch had pinned, and the identity was the attacker.

Sigstore: a ten-minute certificate for an identity you already have

#

Sigstore replaces the long-lived key with an identity the signer already holds. The signer generates a throwaway key pair and proves who it is to Fulcio, Sigstore's certificate authority, with an OpenID Connect (OIDC) token from GitHub, Google, or another provider. Fulcio returns a certificate binding the public key to that identity. The signer signs, records the signature in Rekor, Sigstore's public transparency log, and discards the private key. A verifier checks four things: the signature, the certificate chain to Fulcio, the Rekor inclusion proof, and the identity inside the certificate.

We pulled the provenance attestation for express 5.3.0 from the npm registry and decoded its Fulcio certificate:

Bash
$ curl -s https://registry.npmjs.org/-/npm/v1/attestations/express@5.3.0 > att.json
# extract bundle.verificationMaterial.certificate.rawBytes from the SLSA attestation, base64-decode to provenance.der
$ openssl x509 -inform der -in provenance.der -noout -issuer -dates -ext subjectAltName
issuer=O = sigstore.dev, CN = sigstore-intermediate
notBefore=Oct  9 12:50:48 2026 GMT
notAfter=Oct  9 13:00:48 2026 GMT
X509v3 Subject Alternative Name: critical
    URI:https://github.com/expressjs/express/.github/workflows/npm-publish.yml@refs/tags/v5.3.0

Three details carry the design. The certificate was valid for exactly ten minutes, so there is no signing key left to steal afterward. The subject is not a person; it is a workflow file at a tag. And Fulcio extensions in the same certificate name the OIDC issuer (https://token.actions.githubusercontent.com), the source commit (cf6722bf...), and the run that produced it.

A verifier that skips the identity check has verified nothing useful, because anyone can obtain a valid Fulcio certificate for their own account. Hayden Blauzvern made the point on the Sigstore blog when cosign 2.0 began requiring identity flags in February 2023: "it's critical to specify who you trust to generate a signature for identity-based signing."

What each signature on an npm package proves

#

A single npm package can carry several signatures, and each one makes a different claim. Express 5.3.0, which we inspected on October 30, 2026, carries three signatures on top of its integrity hash.

The figure stacks four layers for express 5.3.0, from bottom to top. Layer 1 is the integrity hash, sha512-qCqy1xSo..., which proves the bytes match a digest and identifies no one. Layer 2 is npm's registry signature, made with ECDSA P-256 key SHA256:DhQ8wR5A...; it proves the npm registry vouched for this name, version, and digest, and it does not say who uploaded the tarball. Layer 3 is npm's publish attestation, signed with the same registry key; it records that npm accepted the publish. Layer 4 is the SLSA provenance attestation, signed with a ten-minute Fulcio certificate whose identity is expressjs/express/.github/workflows/npm-publish.yml@refs/tags/v5.3.0 and logged in Rekor at index 3164578318; it proves that workflow built and signed this tarball, and it does not say whether the source was clean. No layer says what the code does.

Reproducing npm's registry signature by hand

#

npm signs packages published to the public registry with a key it publishes at /-/npm/v1/keys. The signed message is the package name, version, and the tarball's integrity hash, joined as name@version:integrity. That is small enough to verify with openssl:

Python
import json, sys, base64, subprocess, urllib.request
name, ver = sys.argv[1], sys.argv[2]
keys = {k["keyid"]: k for k in json.load(urllib.request.urlopen("https://registry.npmjs.org/-/npm/v1/keys"))["keys"]}
dist = json.load(urllib.request.urlopen(f"https://registry.npmjs.org/{name}/{ver}"))["dist"]
sig = dist["signatures"][0]
open("msg", "w").write(f"{name}@{ver}:{dist['integrity']}")
open("sig.der", "wb").write(base64.b64decode(sig["sig"]))
open("npm.pem", "w").write(f"-----BEGIN PUBLIC KEY-----\n{keys[sig['keyid']]['key']}\n-----END PUBLIC KEY-----\n")
print(subprocess.run(["openssl", "dgst", "-sha256", "-verify", "npm.pem", "-signature", "sig.der", "msg"],
                     capture_output=True, text=True).stdout.strip())
Bash
$ python3 verify_npm_sig.py express 5.3.0
Verified OK
$ printf 'express@5.3.0:sha512-AAAA' > msg && openssl dgst -sha256 -verify npm.pem -signature sig.der msg
Verification failure

Doing this once makes the guarantee concrete. The registry signature binds a name, a version, and a digest to npm's key. It proves you received what the registry stored. Because the registry signs what it accepts, a malicious version's registry signature verifies exactly as well as the one on express.

npm audit signatures automates the same check across an installed tree and also verifies provenance attestations where they exist. One operational detail we hit: attestation verification first downloads Sigstore's trust root over TUF from tuf-repo-cdn.sigstore.dev. In our egress-restricted sandbox, npm audit signatures on npm 10.9.9 exited with npm error Failed to download from tuf-js before checking a single package. In locked-down CI, allowlist that host. The check fails closed, which is the right default, but it fails.

PyPI attestations name the workflow too

#

PyPI's version of the same idea is a PEP 740 attestation, verified by PyPI at upload and served from an integrity API. For urllib3 2.8.0:

Bash
$ curl -s -H 'Accept: application/vnd.pypi.integrity.v1+json' \
    https://pypi.org/integrity/urllib3/2.8.0/urllib3-2.8.0-py3-none-any.whl/provenance \
  | jq '.attestation_bundles[0].publisher'
{ "environment": "publish", "kind": "GitHub", "repository": "urllib3/urllib3", "workflow": "publish.yml" }

The Fulcio certificate inside that bundle is valid from 19:29:31 to 19:39:31 UTC on September 15, 2026, and its subject is https://github.com/urllib3/urllib3/.github/workflows/publish.yml@refs/tags/2.8.0. Same model, same ten-minute window, same answer to "who": a workflow.

How npm, PyPI, Maven Central, and NuGet sign packages

#

npm, PyPI, Maven Central, and NuGet all sign something, but they disagree on what is signed, whose identity it carries, and who checks it. As of October 2026:

RegistryWhat is signedIdentity in the signatureRequired to publishWhat checks it by default
npmname@version:integrity, by the registry; optional Sigstore provenancenpm's registry key; for provenance, the CI workflowRegistry signature always; provenance optionalNothing at install; npm audit signatures on demand
PyPIEach file, via PEP 740 attestationsThe trusted publisher's repository and workflowNoPyPI at upload; consumers via the integrity API
Maven CentralEvery file, via detached PGP .asc; optional Sigstore bundleWhoever holds the PGP keyPGP yes; Sigstore optionalConsumers, if they pin keys; Central validates Sigstore bundles at upload
NuGetEvery package, by nuget.org; optional author signaturenuget.org's certificate; the author's CA-issued certificateRepository signature alwaysTamper check only; untrusted certificates are treated as unsigned

The last column is the one that matters. NuGet's documentation is unusually candid: packages "signed with untrusted certificates are considered as unsigned and are installed without any warnings or errors," unless you set signatureValidationMode to require and list trusted signers in nuget.config. Maven Central added Sigstore validation in 2025, as we reported, while keeping PGP as the required format. In every ecosystem, the default verifier checks that a signature is intact and stops short of asking whether it came from the right identity.

What does code signing not prove?

#

Code signing does not prove that signed code is safe, that the signer is honest, or that the build used the source you reviewed. A signature is a statement made at one step of the pipeline, and everything upstream of that step is invisible to it. npm's documentation says it plainly: provenance "does not guarantee the package has no malicious code."

The figure lays the release pipeline out as five steps: source, build, sign, registry, install. Signature verification happens at install and covers only the sign step onward. It places six incidents on that pipeline. At the source step: the keyv and cacheable compromise, where trojanized source still got valid provenance, and the LiteLLM compromise, where the build took a lookalike repository as input. At the build step: SolarWinds Orion, where the attacker worked inside the vendor, and the TanStack compromise, where a poisoned cache yielded the release job's OIDC token. At the sign step: xz utils, where the attacker was the signer, and OpenAI, where malware ran next to the signing certificate. Every one happened at or before the sign step, where verification cannot see it.

IncidentWhere the attacker wasWhat got signedWhat verification said
SolarWinds Orion, 2020Inside the vendor, before signingA trojanized, code-signed Orion updateValid
xz utils 5.6.0 and 5.6.1, 2024The attacker was a maintainerRelease tarballs with trigger code absent from Git, signed with Jia Tan's keyValid against the pinned key
TanStack, May 2026Binaries in a poisoned CI cache read the release job's OIDC token84 malicious versions, published through the project's trusted publisherTrusted publisher check passed
keyv and cacheable, August 2026Trojanized source, built by the legitimate release workflowkeyv 6.0.0Provenance attestation passed
LiteLLM, October 2026Release workflow pointed at lookalike repo Berri-AI/litellmLiteLLM 1.107.0 container imageProvenance valid, naming the wrong repository
Axios to OpenAI, March 2026Malicious dependency inside the signing workflowNothing malicious found; signing material exposedCertificate revoked as a precaution

The incidents sort into four failure classes. In SolarWinds, TanStack, and keyv, the attacker sat upstream of an honest signer, so the signer signed the attacker's code. In xz, the attacker was the signer, which no signature scheme can detect. In the OpenAI case, attacker code ran in the workflow that held the signing certificate. In LiteLLM, the signature and the identity were both real, and the identity was one hyphen away from the expected one. Only a verifier that compared it against BerriAI/litellm would have noticed.

The keyv payload goes a step further. It includes a component that requests Fulcio certificates and writes Rekor entries, so the versions it republishes carry freshly minted, verifiable Sigstore provenance. Signing is a capability attackers now automate. It is not a signal they lack.

Code signing compared: CA certificates, OpenPGP, Sigstore, and registry signatures

#

The four signing models differ in what identity they bind, how long a key lives, and how they fail.

ModelIdentity bound to the keyKey lifetimeHow a verifier picks the right keyMain failure modeUsed by
X.509 CA certificateOrganization name vetted by a CACertificate up to 460 days since March 2026Chain to an OS root storeStolen or misused long-lived key; revocation breaks good builds tooWindows, macOS, Java, NuGet author signing
OpenPGPWhatever the key holder claimsYears, often no expiryFingerprint pinned out of bandNobody pins, so nobody verifiesMaven Central, Linux distributions, Git tags
Sigstore keylessAn email or CI workflow from an OIDC providerTen minutesPin identity and issuer; check the Rekor logA compromised workflow or stolen OIDC token signs validlynpm provenance, PyPI attestations, CPython 3.14, Maven Central (optional)
Registry signatureThe registry itselfLong-lived registry keyRegistry publishes its keysSigns whatever the registry acceptsnpm, nuget.org repository signatures

Our verdict: for anything built in CI, use Sigstore keyless signing and verify the workflow identity, because it removes the long-lived key that attackers steal. Use CA certificates where the platform requires them, such as Windows, macOS, and NuGet author signing, and keep the key in a job that runs no third-party code. Keep OpenPGP only where consumers already pin your fingerprint, as distributions do. Treat registry signatures as transport integrity. None of the four tells you what the code does.

Code signing, provenance, and checksums answer different questions

#

A checksum answers "are these the bytes I expected?" A signature answers "who vouched for these bytes?" Provenance answers "how and where were these bytes built?" Package provenance is a signed statement, usually in the SLSA provenance format, naming the source repository, the commit, and the build workflow. It is code signing applied to a build record instead of to the artifact alone. Trusted publishing is the registry-side counterpart: it authenticates the publishing workflow instead of a stored token, and on npm and PyPI it usually produces provenance along the way.

Software integrity is the property all three serve: the artifact you run is the artifact its author intended. Hashes protect integrity in transit. Signatures extend it to an identity. Provenance extends it to a build. None of them extends it to intent.

Using code signing without over-trusting it

#

Signing still earns its place. It stops tampering in transit and on mirrors. Keyless signing removes the long-lived keys and tokens that make maintainer account takeover cheap. And a transparency log leaves a public record that makes an incident traceable. The controls below get that value without mistaking it for safety. Each one says what it does not cover.

Verify identity, not just validity. Pass --certificate-identity and --certificate-oidc-issuer to cosign, or compare a PyPI attestation's repository and workflow with the ones you expect. This catches the LiteLLM pattern, where provenance verified but named the wrong source. It does not catch a compromised legitimate workflow.

Watch for identity changes on packages you already depend on. A new signer or publisher on an established package is a stronger signal than any single signature. Socket raises New author when an npm collaborator publishes a package for the first time and Unstable ownership when a new collaborator begins publishing versions. Neither fires when attackers publish through the existing identity, which is what happened with TanStack.

Keep signing keys and OIDC grants away from third-party code. OpenAI traced its exposure to a workflow that used a floating tag and had no minimumReleaseAge. Run signing in a separate job that installs no dependencies and restores no cache, as part of your CI/CD security baseline. This does not protect the artifact a compromised build job hands to the signing job.

Read the package. In the TanStack compromise, the trusted publisher check passed and Socket's AI Scanner flagged all 84 malicious versions within six minutes of publication, based on what changed in the tarballs, including a new 2.3 MB obfuscated router_init.js. Signatures told consumers which pipeline published the code. Only analysis of the contents told them it was malware.

The unsolved part of code signing is policy, not cryptography. Sigstore made signatures cheap and signing keys disposable, so a signature now costs an attacker nothing they do not already have once they are inside a release pipeline. What remains scarce is the expectation a signature should be checked against: which identity releases this package, through which workflow, approved by whom. For most packages in a typical dependency tree, nobody has written that expectation down. Until someone does, most signature checks will confirm that someone signed, and stop there.