# Sign Jenkins builds as the job

Source: https://www.testifysec.com/docs/cilock/guides/jenkins-job-identity/

Register a Jenkins controller with the TestifySec Platform so each build signs with a short-lived certificate naming the job, and keep the controller's signing key across restarts under configuration as code.

On the TestifySec Platform a Jenkins build can sign with its own identity. A tenant admin registers the Jenkins controller as a CI issuer. After that, each build of a registered job exchanges its OIDC token for a short-lived certificate whose only identity is the job URL. The certificate names no person, and Jenkins holds no long-lived signing key.

 

The platform pins the controller's public keys at registration and never fetches anything from the controller, so a controller on a private network works. Pinning has a cost: if the controller's key changes, signing stops until an admin registers the new key. Under configuration as code (JCasC) the key changes on every restart unless the credential lives where JCasC does not manage it. Most of this guide is about that.

 

## What you need

 

- Jenkins with the [OpenID Connect Provider](https://plugins.jenkins.io/oidc-provider/) plugin, plus `credentials-binding`, `plain-credentials` and `cloudbees-folder`.
 - `JENKINS_HOME` on a persistent volume, with backups. The signing key is stored there.
 - An OWNER or ADMIN of your platform tenant who can complete a passkey step-up. Registering an issuer requires a fresh passkey confirmation. API tokens and enrolled agents cannot register one.
 - `cilock` on the agents that run signing steps.

 

## Step 1: Put the job URL in the token

 

The platform admits a token only when its claims are the ones a Jenkinsfile cannot change: `sub` is the job URL, `build_number` is the build number, and `build_url` is the build URL. Set them in the controller's configuration as code (this block is global configuration, not a credential, so it is safe in JCasC):

 

```yaml
security:
  idToken:
    buildClaimTemplates:
      - {name: sub, format: "^${JOB_URL}", type: string}
      - {name: build_number, format: "^${BUILD_NUMBER}", type: integer}
      - {name: build_url, format: "^${BUILD_URL}", type: string}
    globalClaimTemplates:
      - {name: sub, format: "^${JENKINS_URL}", type: string}
```

 

Do not give pipelines parameters named like these template variables.

 

## Step 2: Create the id token credential once, in a folder

 

The plugin generates the credential's RSA key when the credential is created, and it offers no way to supply a key. JCasC re-creates every credential it declares each time it applies, so **an `idToken` declared in JCasC gets a new key at every controller start and every configuration reload.** Leaving it out of JCasC is not enough if it sits in the system credential store: a JCasC `credentials:` block replaces that store, and a credential created there by hand is deleted on the next start.

 

What keeps the key is a credential in a **folder's** credential store:

 

1. Create a folder for the jobs that sign, for example `builds`, and move or create those jobs inside it.
 2. In that folder, open **Credentials** and add an OpenID Connect id token credential (`idToken` in JCasC terms). Give it the ID `jenkins-oidc` and audience `sigstore`. Set **Issuer** to an `https` URL that names this controller, for example `https://jenkins.example.com/oidc/job/builds`. The platform registers only `https` issuers. It treats the value as a name and never connects to it, so it does not need to resolve from the platform.
 3. Do not declare this credential in JCasC, and do not let JCasC or Job DSL re-create the folder.

 

The key is saved in the folder's `config.xml`, encrypted with the controller's `secrets/` directory, and reloaded unchanged when the controller starts.

 

What has been checked: on a controller with a persistent `JENKINS_HOME` and a JCasC file that omits the credential, a folder credential kept the same key through two restarts and a JCasC reload, in two separate runs. In the same test, a credential created by hand in the system store was deleted on the first restart, and one declared in JCasC got a new key at every start and reload.

 

What has not been checked yet, so test it on your controller before relying on it:

 

- A certificate issued from a folder credential. The check read the folder issuer's keys from the plugin but did not mint a token from it.
 - A folder that Job DSL or JCasC creates at startup. Re-creating the folder may re-create its credential store and its key.
 - A controller rebuilt from an empty `JENKINS_HOME`. Its key is new whatever the placement. Restore `JENKINS_HOME` from backup, or register the new key (see [When the key changes](https://www.testifysec.com/docs/cilock/guides/jenkins-job-identity/#when-the-key-changes)).

 

## Step 3: Read the public keys over a channel you trust

 

The plugin serves the folder issuer's public JSON Web Key Set at `<jenkins url>/oidc/job/<folder>/jwks`. The keys are public, but the keys you register decide who can sign as your jobs. If someone substitutes their own key while you read the document, the platform pins their key and issues certificates for their tokens. Read it only over a channel that authenticates the controller:

 

- over HTTPS with certificate verification, for example `curl --fail --proto '=https' https://jenkins.example.com/oidc/job/builds/jwks`; or
 - on the controller host itself, from its loopback address, for example `curl --fail http://127.0.0.1:8080/oidc/job/builds/jwks`.

 

Never pin a document fetched over plain HTTP across a network, or one somebody sent you. It contains public keys only, and it is what the platform pins. Never upload a private key; the platform refuses one.

 

## Step 4: Register the controller

 

A tenant OWNER or ADMIN registers the issuer with the platform's `createCredential` GraphQL mutation, from a browser session that has completed a passkey step-up in the last two minutes:

 

```graphql
mutation Register($i: CreateCredentialInput!) {
  createCredential(input: $i) { credential { id issuerURL subject } }
}
```

 

```json
{
  "i": {
    "name": "jenkins-builds",
    "type": "OIDC",
    "tenantID": "<your tenant id>",
    "scopes": ["attestation:upload"],
    "issuerURL": "https://jenkins.example.com/oidc/job/builds",
    "audience": "sigstore",
    "subject": "http://jenkins.example.com:8080/job/builds/*",
    "ciIssuerKind": "JENKINS",
    "pinnedJwks": "<the JWKS document from step 3, as a string>"
  }
}
```

 

- `issuerURL` must equal the credential's Issuer exactly.
 - `subject` is the job URL the registration covers: one job URL ending in `/`, or a URL ending in `/*` that covers every job below it. `.../job/builds/*` covers `.../job/builds/job/app/` and never `.../job/builds-other/`.
 - A job covered by two registrations of your tenant is refused at signing time. Do not register a second row for the same jobs; revoke the old one first.
 - Another tenant cannot register jobs your registration covers, or pin different keys for your issuer.

 

## Step 5: Sign in the pipeline

 

Bind the credential around the cilock step only. The plugin mints the token when `withCredentials` binds it, and cilock exchanges it for a certificate when it signs, after the wrapped command ends:

 

```groovy
withCredentials([string(credentialsId: 'jenkins-oidc', variable: 'FT')]) {
    sh 'cilock run --step build --platform-url "$PLATFORM_URL" --signer-fulcio-token "$FT" -o build.json -- ./build.sh'
}
```

 

`PLATFORM_URL` is your platform's URL. Keep the `sh` string in single quotes so Groovy does not interpolate the token. A build of a job no registration covers gets no certificate.

 

## When the key changes

 

If the key changes (a JCasC-declared credential, a re-created folder, an empty `JENKINS_HOME`), every signing step fails. Jenkins shows `{"code":3,"message":"There was an error processing the identity token"}`. The platform log says `no public keys able to verify jwt`.

 

The platform never trusts a key that no admin pinned, so a person has to act. Revoke the old registration with `revokeCredential`, then read the new JWKS as in step 3 and register it as in step 4, with a fresh passkey step-up. Revoke first: if both registrations are active, every covered job is refused until one is revoked. No build can sign between the two steps.

 

## Next

 

- [Jenkins pipelines without changing the build script](https://www.testifysec.com/docs/cilock/tutorials/jenkins-pipeline) for adding cilock to a pipeline.
 - [Verify in a release gate](https://www.testifysec.com/docs/cilock/guides/verify-in-a-release-gate) for checking the build's evidence before release.

Reference generated from the product documentation. Match commands and support details to your installed release.
