Keyless software signing from GitHub Actions through OIDC¶
Sign release artifacts from a GitHub Actions pipeline without storing any
long-lived credential in the repository. The job proves its identity with a
short-lived GitHub OIDC token; Secsy PKI verifies it, maps the workflow identity
to the least-privilege signer role, and returns an HSM-backed CMS/PKCS#7
detached signature with an RFC 3161 timestamp. The signing key never leaves
the HSM.
This is the same "workload-identity federation" pattern as PyPI/npm trusted publishing or cosign keyless — here the trust anchor is your PKI.
| File | Purpose |
|---|---|
config.yaml |
Server: GitHub OIDC verifier + signer role mapping + HSM signing service + TSA |
github-workflow.yml |
Drop-in .github/workflows/sign-release.yml |
verify-signature.sh |
Downstream openssl cms verification (no server needed) |
Reference: docs/signing/artifact-signing.md,
docs/security/authentication.md.
How the trust flows¶
GitHub Actions job (id-token: write)
│ core.getIDToken("secsy-pki-signing") ← short-lived JWT, audience-scoped
▼
POST /api/sign Authorization: Bearer <github-oidc-jwt>
│
├─ Secsy verifies the JWT against GitHub's JWKS (issuer, signature, expiry, aud)
├─ claims.sub = "repo:example-org/example-repo:environment:release"
├─ rbac.subjects maps that sub → [signer] (grants ONLY artifact:sign)
└─ signs digest on the HSM (code-signing key) + RFC 3161 countersignature
▼
{ "signature": "<base64 DER>", "signer_certificate": "...", "timestamped": true }
Two independent controls make this safe: GitHub's IdP asserts which workflow
is calling (the sub claim), and the audience (aud == client_id) ensures a
token minted for some other service can't be replayed here.
The one thing to get right: a stable sub claim¶
Role assignment is an exact match on the OIDC sub claim. GitHub's default
sub embeds the git ref — repo:OWNER/REPO:ref:refs/tags/v1.2.3 — which changes
on every release and can't be pinned in config.
Use a GitHub Environment. When a job runs in an environment, sub becomes:
stable across every tag and branch. The example workflow declares
environment: release; create it under Settings → Environments and pin that
exact string in rbac.subjects. As a bonus you get environment protection rules
(required reviewers, tag restrictions) gating who can sign.
Prefer even tighter identity? GitHub also supports customizing the subject claim (
repository_id,job_workflow_ref, …) per repo via the API. The environment approach needs no API calls and is enough for most teams.
1. Provision the signing material (one time, offline)¶
On an operator host that can reach the HSM:
# A CA to issue the code-signing certificate under (skip if you already have one).
$ secsy-ca -config config.yaml init-root -cn "Example Root" -label "Example Root"
# Generate the code-signing key IN the HSM and issue its certificate under the
# lint-gated code-signing profile (EKU id-kp-codeSigning).
$ secsy-ca -config config.yaml signing-key -ca "Example Root" -label codesign-release \
-cn "Release Signing" -o "Example Corp" -chain -out /etc/secsy/codesign.pem
# The RSA TSA key for the RFC 3161 countersignature.
$ secsy-ca -config config.yaml tsa-key -ca "Example Root" -label tsa-signer \
-cn "Example TSA" -out /etc/secsy/tsa.pem
2. Configure and run the server¶
Edit config.yaml:
oidc.client_id— the audience your workflow requests (heresecsy-pki-signing). It just has to match on both sides.rbac.subjects— replaceexample-org/example-repowith your repo, keep the:environment:releasesuffix.- Point
signing.signers[].certificate_file/tsa.certificate_fileat the files written in step 1.
The server discovers GitHub's OIDC metadata at
https://token.actions.githubusercontent.com/.well-known/openid-configuration on
startup, so it must have outbound network access to GitHub.
3. Add the workflow¶
Copy github-workflow.yml to
.github/workflows/sign-release.yml, set SECSY_URL / SECSY_AUDIENCE, and
create the release environment. On the next published release the job:
- mints a GitHub OIDC token for the
secsy-pki-signingaudience; - hashes the artifact and calls
POST /api/signwith{"signer":"release","digest":"…"}; - saves
<artifact>.p7s(detached CMS, DER) and the signer certificate, and attaches them to the release.
Signing by digest means the artifact bytes never leave the runner — good for multi-GB images (the API caps request bodies at 8 MiB regardless).
CAdES level (optional). Every signature is at least CAdES-B. If you provision
the built-in TSA (secsy-ca tsa-key + tsa.enabled),
add "level":"t" to the request for an embedded RFC 3161 timestamp, or "level":"lt"
to additionally embed the chain's CRLs/OCSP for offline long-term validation —
so releases stay verifiable after the signer certificate expires. The response
reports the achieved level. See CAdES baseline levels.
4. Verify downstream (no server, no HSM)¶
Anyone with your root CA can verify, offline, with standard tooling:
$ ./verify-signature.sh app-v1.2.3.tar.gz app-v1.2.3.tar.gz.p7s root.pem
OK: app-v1.2.3.tar.gz verifies against root.pem
verify-signature.sh wraps:
$ openssl cms -verify -binary -inform DER -in app-v1.2.3.tar.gz.p7s \
-content app-v1.2.3.tar.gz -CAfile root.pem -purpose any -out /dev/null
For the full check — including that the embedded timestamp verifies and the signer has the code-signing shape — use the CLI (also HSM-free):
$ secsy-ca verify-signature -sig app-v1.2.3.tar.gz.p7s -in app-v1.2.3.tar.gz \
-ca-file root.pem -require-timestamp
Add -require-level t (or lt) to fail unless the signature actually reached that
CAdES level — a
stronger, self-describing gate than -require-timestamp alone.
The timestamp is why this keeps verifying after the signing certificate expires — the chain is validated at the token's genTime, not the wall clock.
Security properties¶
- No stored secret. The credential is a per-run, minutes-long OIDC token. Nothing to leak, rotate, or exfiltrate from repo secrets.
- Least privilege. The
signerrole grants exactlyartifact:sign(and reading its own audit entries) — not certificate issuance. A compromised pipeline can sign builds; it can never mint certificates.signeris a distinct role fromissuerby design. - Auditable. Every call writes an
artifact.signevent (signer, artifact digest, whether a countersignature was embedded) to the tamper-evident log, andPOST /api/signis rate-limited + HSM-concurrency-guarded so a runaway matrix build can't starve the HSM. - Scoped to one workflow identity. Only the exact
subinrbac.subjectsis accepted; other repos/branches/environments resolve to zero roles and are rejected.
Note: the machine-Bearer verifier is global¶
The top-level oidc block is the verifier for all Authorization: Bearer
machine callers, so pointing it at GitHub dedicates that path to GitHub OIDC. If
human operators also sign in to the console, configure their corporate IdP
separately under auth.oidc.issuer_url — that interactive-login provider is
independent of this machine-Bearer one (they may be different issuers). See
docs/security/authentication.md.
Adapting to other CI systems¶
Any OIDC-issuing CI works the same way — point oidc.issuer_url at its issuer,
request a token with the matching audience, and map its sub in rbac.subjects:
| CI | Issuer | Typical sub |
|---|---|---|
| GitHub Actions | https://token.actions.githubusercontent.com |
repo:OWNER/REPO:environment:NAME |
| GitLab CI | https://gitlab.com (or self-managed URL) |
project_path:GROUP/PROJECT:ref_type:branch:ref:main |
| Buildkite | https://agent.buildkite.com |
organization:ORG:pipeline:PIPELINE:… |
Only one machine-Bearer issuer is verified per deployment; run a dedicated signing deployment (or use scoped API tokens) if you must federate several at once.