Penv Cloud
Start free
Docs
Referencesince cloud@2026-09-17

Authentication

Exchanging a host proof for a short-lived pck_ credential: OIDC, delivery, AWS and keypair exchanges, lifetimes, permissions, PENV_TOKEN and revocation.

Authorization: Bearer pck_...

Every call you make carries this header. Your host proves its identity once through an exchange, and we answer with a short-lived credential scoped to one environment (machine identity).

Principals

PrefixHolderSourceLifetime
pck_MachineAn exchange below, or a token issued in the consoleUp to 15 minutes from an exchange
pcu_Personpenv login, approved in a browser30 days from last use, at most 90 days

A pcu_ credential carries no project and no environment, so we answer it 403 forbidden on the secrets and dynamic operations. Only POST /api/v1/auth/revoke accepts it. To call the API directly, you need a pck_.

Exchange a CI platform token

T="$ACTIONS_ID_TOKEN" jq -n '{token:env.T}' | curl -X POST https://penv.cloud/api/v1/auth/oidc \
  -H "content-type: application/json" --data-binary @-
{ "credential": "pck_...", "expiresAt": "2026-09-17T12:15:00.000Z" }

jq puts the token in the body and curl reads it on stdin, so it stays out of the process list.

The token's audience must be your workspace id, so we refuse a token minted for another workspace. A slug is not accepted as the audience: a workspace name is not unique, so only the id names one workspace. The exchange is unauthenticated; the token is the proof. Per-platform token setup: connect.

StatusCodeMeaning
400token_requiredNo token in the body.
400audience_not_workspace_idThe audience is your workspace slug. Mint the token with the workspace id (the Organization ID under Settings, Organization).
403refusedA GitHub Actions token from a pull_request_target run. We refuse that trigger on both OIDC exchanges (CI builds).
503unavailableWe could not reach the issuer. Retry; your trust is not at fault.

Exchange for a delivery credential

T="$ACTIONS_ID_TOKEN" jq -n '{token:env.T}' | curl -X POST https://penv.cloud/api/v1/auth/oidc/delivery \
  -H "content-type: application/json" --data-binary @-

Same body; we bind the credential to one GitHub Actions run for five minutes. It records the repository, workflow, run id and attempt, so your audit row names one execution. We answer a pull_request_target run 403 refused, because that trigger runs untrusted pull request code with base-repository permissions (CI builds).

Exchange an AWS role

POST /api/v1/auth/aws takes an sts:GetCallerIdentity request you have already signed with your workload's instance, task or function role (AWS STS). We replay it to AWS to learn the signer. The signature is valid for that one call, so no secret leaves your machine.

signed-request.json
{
  "method": "POST",
  "url": "https://sts.us-east-1.amazonaws.com/",
  "body": "Action=GetCallerIdentity&Version=2011-06-15",
  "headers": {
    "authorization": "AWS4-HMAC-SHA256 Credential=...,SignedHeaders=...,Signature=...",
    "x-amz-date": "20260917T120000Z",
    "x-penv-cloud-org": "4f1c1e6a-7c2e-4c1a-9b6e-2f0d1a8c3b55"
  }
}
curl -X POST https://penv.cloud/api/v1/auth/aws \
  -H "content-type: application/json" \
  --data-binary @signed-request.json
RuleDetail
x-penv-cloud-orgYour workspace id. Must be listed in SignedHeaders; anyone who captured the request could rewrite an unsigned copy, so we refuse it.
Wrong shape400 signed_request_required.
ReplayWe accept each signed request once.

Exchange a signed challenge

For a host with no signed metadata and no secure element, such as a plain VPS. Your host holds an Ed25519 private key, we hold the public half, and a counter both sides remember advances on every login. The CLI runs these steps in the machine enroll command.

Enroll once, spending the one-time pce_ secret from the console and binding the public key (SPKI DER, base64).

curl -X POST https://penv.cloud/api/v1/auth/keypair/enroll \
  -H "content-type: application/json" \
  -d '{"secret":"pce_...","publicKey":"MCowBQYDK2VwAyEA..."}'
{ "credentialId": "0f3c1a2b-...", "generation": 1 }

Request a nonce. We issue it for 120 seconds, so brief access to your host cannot stockpile pre-signed challenges.

curl -X POST https://penv.cloud/api/v1/auth/keypair/challenge \
  -H "content-type: application/json" \
  -d '{"credentialId":"0f3c1a2b-..."}'
{ "nonce": "..." }

Sign the UTF-8 bytes of these four lines joined by newlines, base64 the signature, and exchange it.

penv-cloud:keypair:v1
0f3c1a2b-...
<nonce>
1
curl -X POST https://penv.cloud/api/v1/auth/keypair \
  -H "content-type: application/json" \
  -d '{"credentialId":"0f3c1a2b-...","nonce":"...","generation":1,"signature":"..."}'
{ "credential": "pck_...", "expiresAt": "2026-09-17T12:15:00.000Z", "generation": 2 }

Write the returned generation to disk before using the credential.

If we do not hold the generation you present, we answer 409 cloned, revoke the keypair and everything it minted, and send your workspace a security email. Run one agent per host; two processes racing a re-authentication present the same number twice, which reads as a copied key.

Lifetimes

ExchangeLifetime
POST /api/v1/auth/oidc, /aws, /keypair15 minutes (EXCHANGED_CREDENTIAL_TTL_MS)
POST /api/v1/auth/oidc/delivery5 minutes (DELIVERY_TTL_MS)
POST /api/v1/auth/keypair/challenge120 seconds for the nonce (CHALLENGE_TTL_MS)

Every exchange that mints a pck_ takes an optional ttlSeconds, a whole number of seconds, at least 60. We mint the shorter of it and the lifetime above; a longer ask is capped, not refused. Ask for less when an agent drives the session. Leave it out for the full lifetime. A value that is not a whole number answers 400 ttl_must_be_an_integer, and one under 60 answers 400 ttl_too_short.

We cap every expiry at the trust's own expiry, so a credential never outlives its rule. Read expiresAt rather than counting minutes.

Permissions

We resolve your credential in one indexed lookup to a workspace, project, environment, the identity's roles and the plan the rate limit reads. The scope is fixed when you create the identity; no request can widen it.

OperationPermission
GET /api/v1/secretssecret:read
GET /api/v1/secrets/{address}secret:reveal
GET /api/v1/bulk-read-secretssecret:reveal
PUT /api/v1/secrets/{address}secret:write
DELETE /api/v1/secrets/{address}secret:delete
POST /api/v1/dynamic/{address}/leasedynamic:generate

Listing names and opening values are separate grants, so you can make a list-only identity.

StatusCodeMeaning
403forbiddenValid credential without the permission.
401expiredPast its expiry.
401unauthorizedAnything else, revoked included.

PENV_TOKEN

PENV_TOKEN=pck_... penv run -- pnpm build

penv reads a pck_ from PENV_TOKEN before the OS keychain. penv run passes it to the child, so set it on the step that needs it (environment variables, CI builds). The published GitHub Action reads no PENV_TOKEN: it mints and revokes a delivery credential per run.

Revoke a credential

curl -X POST https://penv.cloud/api/v1/auth/revoke \
  -H "Authorization: Bearer pck_..."
{ "revoked": true }

We revoke only the credential in the header, looked up by the hash of its secret. You need no permission, since you could discard the secret anyway. Idempotent: an already-revoked credential answers 200.

Call it at the end of your job and ignore a failure; the short lifetime bounds the credential, and revocation only shortens the window.

Next: rate limits.