Docs
Guidesince cloud@2026-09-10

Connect your CI

Your pipeline proves who it is with a token its runner mints, then runs one step with a credential that lives fifteen minutes.

Your CI logs in like a person does, with no key to steal.

There is no long-lived key in your repository settings. Your runner asks its own platform for a signed token. penv.cloud checks that token against a trust you set up once and hands back a credential that lives fifteen minutes.

Set up the trust

Open Machine Identities then Connect a Platform in the console. Pick your platform and answer the fields that identify the workload. The console creates the identity and the trust, then prints the snippet with your workspace id already in it.

Three things to know about the trust:

  • The audience every token must carry is your workspace id, so a token minted for one workspace cannot authenticate at another.
  • Subjects are matched exactly. There are no wildcards.
  • The trust stores no secret. It is a statement that a token from one issuer, addressed to this workspace, carrying one exact subject, is a given identity.

Ask for this audience and no other. A token requested for two audiences fails every exchange.

Run one step with the values

The credential goes in PENV_TOKEN, which takes precedence over the keychain and is how CI and servers carry one. penv run then reads the environment that credential is bound to.

.github/workflows/deploy.yml
permissions:
  id-token: write          # without this, no token is issued at all

steps:
  - name: Install penv
    run: |
      curl -fsSL https://penv.cloud/install | sh
      echo "$HOME/.penv/bin" >> "$GITHUB_PATH"

  - name: Deploy
    run: |
      T=$(curl -sS \
        -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
        "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=YOUR_WORKSPACE_ID" | jq -r .value)
      C=$(curl -sS "https://penv.cloud/api/v1/auth/oidc" \
        -H 'content-type: application/json' \
        -d "{\"token\":\"$T\"}" | jq -r .credential)
      PENV_TOKEN="$C" penv run -- npm run deploy

The installer writes to $HOME/.penv/bin and edits no startup file, so the $GITHUB_PATH line is what puts penv in reach of the steps that follow. Both curl calls read their answer with jq, so a runner without it needs it installed alongside penv.

Both tokens stay inside that one step and that one process. A token that reaches a step output or the job environment is a token every later step and every debug log can read.

For a platform that needs a file on disk instead, PENV_TOKEN="$C" penv pull writes a plain .env in the build step. See Deployment for which of the two fits your host.

The published action

GitHub Actions has a second path that does the same work: an action you reference in your workflow. It reads no repository secrets and mints the token per run, then revokes the credential when the job ends whether the job passed or failed. Its exchange is a different one, and the credential it hands back lives five minutes, because it is spent inside a single step.

InputRequiredWhat it is
audienceyesYour workspace id, which is also the token's audience.
runyesThe command that receives the values. No other step does.
api-originnoDefaults to https://penv.cloud.
filesnoOne NAME=path/name per line, for a tool that cannot read a variable. The value is written to a 0600 file under $RUNNER_TEMP, NAME is set to that path, and the file is removed before the step ends.

Reference the action by its full commit SHA. A tag can be moved to point at other code, which is how CVE-2025-30066 put a credential stealer into thousands of pipelines in March 2025.

What this does not promise

A malicious step in the same job can reach what that job holds. Co-location is disclosure, and no masking changes that: the payload behind CVE-2025-30066 read the runner's own memory and lifted its secret table past the log masker.

What bounds a credential here is its lifetime: fifteen minutes from the exchange above, five from the action's. Revocation at the end of a job is best effort, since a force-cancel or a killed runner runs nothing afterward. A job that restores a cache should not be carrying one at all.

How penv works