Authenticate CI without a long-lived secret

Workload identity in a pipeline.

Written By Dustin

Last updated 26 minutes ago

Storing a long-lived token in CI secrets makes that store the weakest link. TOVIO exchanges the OIDC token your CI provider already issues for a short-lived, repository-bound session instead.

Trust the issuer once

tovio agent issuer-policy github-main \  --issuer https://token.actions.githubusercontent.com \  --audience tovio-forge \  --subject repo:my-org/my-repo:ref:refs/heads/main \  --file github-main.vex

The output is a public, owner-signed artifact. Deploy it to the served repository at .tovio/forge/runner-issuers/<policy-id>.vex. Never copy the owner key to a runner.

--subject is repeatable, required, and matched exactly — there is no glob. This is the setting to get right: a loose subject lets any workflow in the organisation, including one added by anyone who can open a pull request, authenticate as your pipeline.

Optional narrowing: --path and --deny-path (repeatable; the scope defaults to **), --op (defaults to read,relay:fetch), --max-session-seconds (default 900, ceiling 3600), and --expires-in in hours (default 168, maximum 31 days). Anything unsafe, malformed, unbounded, or unverifiable is refused with TVO-CI-010 and no policy is published.

Exchange per run

tovio agent session oidc <remote> <repository_id> \  --policy github-main \  --id-token-file "$RUNNER_TEMP/token.jwt" \  --passphrase-file "$RUNNER_TEMP/pass.txt" \  --file "$RUNNER_TEMP/session.bundle" \  --task-id "$GITHUB_RUN_ID"

Both credentials are file references: the provider token is read once and zeroized, and an independently mounted passphrase encrypts the generated runner key in the output bundle. The destination must not already exist. --ttl-seconds defaults to 900 and is capped by the owner policy, the JWT's own expiry, and a 3600-second ceiling. A malformed, refused, or inconsistent exchange is TVO-CI-011, and no unverified bundle is ever published.

In GitHub Actions the bridge's action does this for you when you pass oidc-policy and oidc-audience. Give the job id-token: write. The action reads GitHub's ambient ID-token request URL and token — neither is an action input or a child argument — stores the JWT and a random passphrase in a private RUNNER_TEMP directory, and removes that directory afterwards.

What the runner ends up holding

Not your identity. The issued token is forced least-privilege: protected paths denied, secret_clearance = false, delegation forbidden, destructive/policy/force-tag operations forbidden, and a unique agent lane. Grants expire within one hour, challenges within five minutes, and the Forge retains only a verified digest of the workload token for replay denial — never the bearer itself.

Where OIDC is unavailable

tovio agent session export <token_id> bundle.enc \  --passphrase-file pass.txt --expires-in 1

A passphrase-encrypted runner bundle. Mount the bundle and the passphrase as two separate secret-store files and pass their paths as agent-session-bundle-file and agent-session-passphrase-file. This is a stored credential, so scope and expire it tightly — the default grant is one hour.

The bridge never accepts a credential value

Not as an action input, not in argv, not in repository config. Only mounted file paths, forwarded through the child's TOVIO_AGENT_SESSION_*_FILE environment. A URL with embedded HTTP(S) credentials is refused outright with TVO-CI-007, before any child is started.

Clearance fails closed

A workflow step that references a secret the job's token is not cleared for is refused with TVO-CI-006 by the runner's clearance gate before the step runs. The job never runs with a referenced secret silently absent or empty, and never sees material its token is not cleared for. The message names the secret and the step index, never a value. Job tokens are minted least-privilege with secret_clearance = false, so this is the default outcome until you grant clearance for that secret's policy path.

A runner fault — a crash, an eviction, a timeout, no conformant runner, an interpreter error — resolves the check to failure, never to success and never to silence. There is no default pass anywhere in this path.