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.vexThe 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 1A 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.