Docs
Referencesince cloud@2026-09-17

Authentication

How a machine trades a proof it already has for a short-lived pck_ credential, what that credential resolves to, and how to throw it away.

Every call carries one header.

Authorization: Bearer pck_...

A credential works like a visitor badge. The host proves who it is once, gets a badge that opens one door for fifteen minutes, and the badge is worth nothing after that.

Two principals

PrefixWho holds itWhere it comes fromHow long it lasts
pck_A machineAn exchange below, or a token you issue in the console15 minutes from an exchange
pcu_A personpenv login, approved in a browser30 days from last use

A pcu_ credential is the command line's own login. It carries no project and no environment, so the secrets and dynamic operations answer it with 403 and forbidden. The one operation that takes it is POST /api/v1/auth/revoke, which revokes it. Calling the API by hand means holding a pck_.

Trade a CI platform token

POST /api/v1/auth/oidc takes the signed token your runner mints and hands back a credential. The token's audience has to be your workspace id, so a token minted for someone else's workspace cannot be used at yours. The exchange is unauthenticated because the token is the proof.

curl -X POST https://penv.cloud/api/v1/auth/oidc \
  -H "content-type: application/json" \
  -d "{\"token\":\"$ACTIONS_ID_TOKEN\"}"
{ "credential": "pck_...", "expiresAt": "2026-09-17T12:15:00.000Z" }

A missing token answers 400 with token_required. An unreachable issuer answers 503 with unavailable, which means try again rather than fix your trust.

The delivery variant, for values that enter a build

POST /api/v1/auth/oidc/delivery takes the same body and mints a credential bound to one GitHub Actions run. It records the repository, the workflow, the run id and the attempt, so an audit row names one execution. It lives five minutes, because nothing after the read needs it.

curl -X POST https://penv.cloud/api/v1/auth/oidc/delivery \
  -H "content-type: application/json" \
  -d "{\"token\":\"$ACTIONS_ID_TOKEN\"}"

A pull_request_target run answers 403 with refused. That trigger runs untrusted pull request code with the base repository's permissions, so the exchange turns it away by name rather than pretending the token was bad.

Trade an AWS role

POST /api/v1/auth/aws takes a sts:GetCallerIdentity request your workload has already signed with its own instance, task or function role. Penv Cloud replays it to AWS to learn who signed. Nothing secret leaves your machine, because the signature is good for that one call only.

{
  "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

x-penv-cloud-org carries your workspace id and has to be inside SignedHeaders. A copy sitting outside the signature can be rewritten by anyone who captured the request, so the server refuses one that is merely present. A body that is not that shape answers 400 with signed_request_required. Each signed request is accepted once.

Trade a signed challenge, for a host that proves nothing

A plain VPS signs no metadata and has no secure element. It holds an Ed25519 private key instead, Penv Cloud holds the public half, and a counter that both sides remember advances on every login.

Enroll once. POST /api/v1/auth/keypair/enroll spends the one-time pce_ secret the console showed you and binds the public key, SPKI DER in 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 }

Ask for a nonce. POST /api/v1/auth/keypair/challenge returns a one-off string that is valid for 120 seconds. It is server-issued, so nobody with brief access to the host can walk away with a stash of pre-signed challenges.

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

Sign and exchange. Sign the UTF-8 bytes of four lines joined by newlines, base64 the signature, and post it to POST /api/v1/auth/keypair.

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 you use the credential. A generation the server does not hold answers 409 with cloned, which revokes the keypair and everything it minted, and sends the workspace a security email. Run one agent per host: two processes racing a re-authentication present the same number twice, and that reads exactly like a copied key.

How long each one lasts

ExchangeLifetimeWhere the number is set
POST /api/v1/auth/oidc15 minutesEXCHANGED_CREDENTIAL_TTL_MS, packages/store/src/machine-identities.ts
POST /api/v1/auth/aws15 minutesthe same constant
POST /api/v1/auth/keypair15 minutesthe same constant
POST /api/v1/auth/oidc/delivery5 minutesDELIVERY_TTL_MS, in the route
POST /api/v1/auth/keypair/challenge120 seconds for the nonceCHALLENGE_TTL_MS, packages/store/src/bound-keypair.ts

Every minted expiry is capped at the trust's own expiry, so a credential never outlives the rule that produced it. Read expiresAt from the response rather than counting minutes yourself.

What the bearer resolves to

One indexed lookup turns the credential into a workspace, a project, an environment, the roles the identity holds and the plan the rate limit reads. That scope is fixed when the identity is created and nothing in a request can widen it.

Each operation asks for one permission.

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, which is what makes a list-only identity possible. A credential that is real and lacks the permission answers 403 with forbidden. A credential past its expiry answers 401 with expired, and everything else, revoked included, answers 401 with unauthorized.

PENV_TOKEN

PENV_TOKEN is where the command line looks for a pck_. It comes before the operating system keychain, so a container built from a developer's image still uses the credential the pipeline gave it.

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

Set it on the step that needs it. penv run passes the whole inherited environment to the child, so a token set job-wide reaches every later command too.

Our published GitHub Action reads no PENV_TOKEN. It mints its own delivery credential per run from the runner's OIDC token and revokes it in a post: step, so nothing crosses a step boundary.

Throw a credential away

POST /api/v1/auth/revoke revokes the credential in the header and reaches no other row, because the hash of that secret is the only thing it looks up. It needs no permission, since a holder could already discard the secret.

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

It is idempotent, so an already-revoked credential still answers 200. Call it at the end of a job and ignore a failure. The short lifetime above is what bounds the credential; this only shortens the window.

Next: rate limits.