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.
Tightening it further: a signing-policy rule¶
RBAC answers who may sign. A
CEL signing rule answers what
they may sign with it — and, because a rule reads the verified claims of
the very token the pipeline presented, it can bind the key to the repository
without depending on the sub claim's shape at all:
secsy-ca issuance-policy add -kind signing -name release-key-is-pipeline-only -keys release-signing -operations artifact -expr 'string(jwt.iss) == "https://token.actions.githubusercontent.com" &&
has(jwt.repository) && string(jwt.repository) == "OWNER/REPO"' -message 'the release-signing key is reserved for the OWNER/REPO release workflow'
# And a release signature that would stop verifying when its certificate
# expires is not an archive:
secsy-ca issuance-policy add -kind signing -name artifacts-need-a-timestamp -operations artifact -expr 'signing.artifact.timestamped' -message 'an artifact signature must be timestamped (level t or lt)'
Both are evaluated inside the signing service, so they bind on POST /api/sign
and on secsy-ca sign alike. A refusal comes back as HTTP 422 carrying the
message above, which is what the pipeline's log should show instead of
"forbidden". Write a rule -disabled first and dry-run it
(secsy-ca issuance-policy test -kind signing -rule <name> -facts facts.json):
a rule is fail-closed, so a wrong one refuses every signature in its scope.
Note: the machine-Bearer path is a separate axis from console login¶
The top-level oidc block is the verifier for all Authorization: Bearer
machine callers. Pointing the single form at GitHub dedicates that path to
GitHub OIDC; to trust several machine issuers at once, declare them under
oidc.providers instead, each with its own audience and claim → role mapping.
Either way, human console logins are a separate axis: configure their
corporate IdP under auth.oidc (or auth.oidc.providers). See
examples/multi-oidc/ and
docs/security/authentication.md#multiple-oidc-providers.
Adapting to other CI systems¶
Any OIDC-issuing CI works the same way — point a provider's issuer_url at its
issuer, request a token with the matching audience, and map its sub to a role:
| 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:… |
Several of them can be federated in one deployment — declare one entry per CI
under oidc.providers, each with its own audience and role_mappings:
oidc:
providers:
- name: github
issuer_url: "https://token.actions.githubusercontent.com"
client_id: "secsy-pki-signing"
role_mappings:
- { claim: "sub", value: "repo:example-org/app:environment:release", roles: ["signer"] }
- name: gitlab
issuer_url: "https://gitlab.com"
client_id: "secsy-pki-signing"
role_mappings:
- { claim: "sub", value: "project_path:group/project:ref_type:branch:ref:main", roles: ["signer"] }
A token is routed to exactly one provider by its iss claim, and a provider's
mappings apply only to tokens it verified — so the GitLab pipeline cannot claim
the GitHub workflow's grant by asserting its subject string. See
examples/multi-oidc/.