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
| Prefix | Who holds it | Where it comes from | How long it lasts |
|---|---|---|---|
pck_ | A machine | An exchange below, or a token you issue in the console | 15 minutes from an exchange |
pcu_ | A person | penv login, approved in a browser | 30 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.jsonx-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>
1curl -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
| Exchange | Lifetime | Where the number is set |
|---|---|---|
POST /api/v1/auth/oidc | 15 minutes | EXCHANGED_CREDENTIAL_TTL_MS, packages/store/src/machine-identities.ts |
POST /api/v1/auth/aws | 15 minutes | the same constant |
POST /api/v1/auth/keypair | 15 minutes | the same constant |
POST /api/v1/auth/oidc/delivery | 5 minutes | DELIVERY_TTL_MS, in the route |
POST /api/v1/auth/keypair/challenge | 120 seconds for the nonce | CHALLENGE_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.
| Operation | Permission |
|---|---|
GET /api/v1/secrets | secret:read |
GET /api/v1/secrets/{address} | secret:reveal |
GET /api/v1/bulk-read-secrets | secret:reveal |
PUT /api/v1/secrets/{address} | secret:write |
DELETE /api/v1/secrets/{address} | secret:delete |
POST /api/v1/dynamic/{address}/lease | dynamic: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 buildSet 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.