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
| Prefix | Holder | Source | Lifetime |
|---|---|---|---|
pck_ | Machine | An exchange below, or a token issued in the console | Up to 15 minutes from an exchange |
pcu_ | Person | penv login, approved in a browser | 30 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.
| Status | Code | Meaning |
|---|---|---|
| 400 | token_required | No token in the body. |
| 400 | audience_not_workspace_id | The audience is your workspace slug. Mint the token with the workspace id (the Organization ID under Settings, Organization). |
| 403 | refused | A GitHub Actions token from a pull_request_target run. We refuse that trigger on both OIDC exchanges (CI builds). |
| 503 | unavailable | We 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.
{
"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| Rule | Detail |
|---|---|
x-penv-cloud-org | Your workspace id. Must be listed in SignedHeaders; anyone who captured the request could rewrite an unsigned copy, so we refuse it. |
| Wrong shape | 400 signed_request_required. |
| Replay | We 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>
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 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
| Exchange | Lifetime |
|---|---|
POST /api/v1/auth/oidc, /aws, /keypair | 15 minutes (EXCHANGED_CREDENTIAL_TTL_MS) |
POST /api/v1/auth/oidc/delivery | 5 minutes (DELIVERY_TTL_MS) |
POST /api/v1/auth/keypair/challenge | 120 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.
| 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, so you can make a list-only identity.
| Status | Code | Meaning |
|---|---|---|
| 403 | forbidden | Valid credential without the permission. |
| 401 | expired | Past its expiry. |
| 401 | unauthorized | Anything else, revoked included. |
PENV_TOKEN
PENV_TOKEN=pck_... penv run -- pnpm buildpenv 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.
Penv Cloud API
HTTP API for reading and writing the values in one penv.cloud project and environment: base URL, operation groups and scope.
Connect an app (authorization code + PKCE)
How an app that runs on a person's machine gets read-only access to the keys that person chose to share: the /connect page, the one-time code, the grant, the exchange and revocation.