Penv Cloud
Start free
Docs
Referencesince cloud@2026-10-28

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.

An app that a person runs, such as a desktop app or a browser extension, can read some of that person's keys without holding a penv password or a long-lived token. The person approves the connection on penv.cloud and picks the keys. The app gets a grant it can trade for short-lived, read-only credentials. Only registered apps can start this flow. You can register your own in the console.

The flow is OAuth 2.0 authorization code with PKCE. We accept the S256 challenge method only.

The flow

  1. The app opens /connect in the person's browser.
  2. The person signs in if needed, picks an organization, project and environment, ticks keys and chooses Allow or Cancel.
  3. We redirect to the app with a one-time code, or with an error.
  4. The app trades the code and its PKCE verifier for a grant (pca_).
  5. The app trades the grant for a pck_ credential, and receives a new grant to use next time.
  6. The app reads the environment with the pck_ credential.

1. Open /connect

https://penv.cloud/connect?client_id=example-app
  &redirect_uri=https%3A%2F%2Fexample.invalid%2Fcallback
  &state=fake-state-value
  &code_challenge=fake-s256-challenge
  &code_challenge_method=S256
  &keys=EXAMPLE_API_KEY,OTHER_TOKEN
  &env_hint=acme/web/staging
ParameterRequiredMeaning
client_idyesThe app's registered id, app_… (register an app).
redirect_uriyesOne of the redirect URIs registered for the app. Compared exactly, character for character.
stateyesA random value the app made. We return it unchanged. 1 to 1,024 printable characters.
code_challengeyesThe base64url SHA-256 of the app's code_verifier.
code_challenge_methodyesS256. Anything else is refused.
keysyesThe key names the app wants, comma separated. The person can untick any.
env_hintnoorg/project/environment. Preselects that environment when the person can use it. Otherwise ignored.

A parameter given twice is an error. The app generates a fresh state and code_verifier for every attempt, and keeps the verifier to itself.

2. The person decides

The page shows the app's name and website, lists each requested key with its status in the chosen environment, and says the access is read only. A key the environment does not have cannot be ticked. A write-only environment, and an environment that requires approval for changes, cannot be chosen. The person also needs permission to reveal values in the environment. See Connect an app for the person's side.

3. The redirect

ChoiceRedirect
Allowredirect_uri?code=<code>&state=<state>
Cancelredirect_uri?error=access_denied&state=<state>
A malformed state, challenge, method or keysredirect_uri?error=invalid_request, with state when it was valid

The code works once and lasts 60 seconds. If client_id or redirect_uri is wrong we show the error on penv.cloud and never redirect, so a bad link cannot send a person to an address we did not register.

4. Trade the code for a grant

POST /api/v1/auth/app/token takes no credential.

{
  "client_id": "example-app",
  "code": "fake-code",
  "code_verifier": "fake-code-verifier",
  "redirect_uri": "https://example.invalid/callback"
}

The answer is 201:

{
  "grant": "pca_fake",
  "connection": {
    "id": "00000000-0000-0000-0000-000000000000",
    "org": "acme",
    "project": "web",
    "environment": "staging",
    "keys": ["EXAMPLE_API_KEY"]
  }
}

We burn the code the moment we receive it. A wrong verifier, a wrong redirect_uri or a second try therefore answers 400 and the app starts over at step 1. Store the grant like a password: it is the app's only long-lived proof.

5. Trade the grant for a credential

POST /api/v1/auth/app takes no credential.

{ "grant": "pca_fake", "ttlSeconds": 300 }

The answer is 201 with credential (pck_), expiresAt and a new grant. Save the new grant and discard the old one. Each grant works once. Run one exchange at a time per connection, or two requests will present the same grant.

If a grant that was already replaced is presented again, we treat it as a copy: we answer 400 invalid_grant, revoke the whole connection with every credential it minted, and write an audit entry. The person sees the app as disconnected and the app has to ask again.

6. Read the environment

GET /api/v1/envs/{org}/{project}/{environment}
Authorization: Bearer pck_fake

The answer holds only the keys the person ticked. Other keys are left out entirely, not masked and not counted. The credential reads; it cannot write, list other environments or reach a write-only environment. See Authentication for the rest of the credential's rules.

Lifetimes

ItemLifetime
Connect code60 seconds, one use
pck_ credentialThe shortest of ttlSeconds and 15 minutes. 15 minutes when you leave ttlSeconds out
GrantReplaced at every exchange. A connection unused for 90 days expires

ttlSeconds follows the machine exchange rules: a whole number, at least 60. A longer ask is capped, not refused; a smaller one answers 400 invalid_request with a detail.

Disconnect

WhoHowAnswer
The appPOST /api/v1/auth/revoke with Authorization: Bearer pca_… (the current grant)204
The personDELETE /api/v1/app-connections/{id} with their pcu_ credential, or the console204, or 404 not_found

An app that only wants to drop the pck_ it holds sends that pck_ to /auth/revoke instead: 204, and the connection stays.

Either way the grant and every live pck_ from it stop working at once. A replaced grant sent to /auth/revoke also ends the connection, but the audit entry records it as grant reuse, as at the exchange. GET /api/v1/app-connections lists the person's live connections. The person's calls need a pcu_ credential; an app or machine credential answers 403.

The connection also ends at the next exchange, and its pck_ stops working, when the workspace starts requiring single sign-on and the person did not approve the connection through its identity provider, or, once the grace period is over, two-factor authentication the person has not set up.

Errors

Bodies are { "error": "<code>", "detail": "<text>" }; detail is optional.

StatusCodeCause
401invalid_clientNo registered app has that client_id, or the app was deleted (token endpoint).
403app_unavailableWe suspended the app. Returned by the token and exchange endpoints. /connect shows it on the page.
403app_unverifiedThe app is still in testing mode and the person is not a member of the organization that owns it. Returned by the token and exchange endpoints. /connect shows it on the page.
400invalid_redirect_uriThe redirect_uri is not one registered for the app. On /connect we show this on the page and do not redirect.
400invalid_requestA parameter is missing, repeated or malformed, or ttlSeconds is not allowed.
400invalid_grantThe code or grant is wrong, expired, already used, replaced or revoked, or the verifier does not match. The exchange also answers this for a deleted app.
redirectaccess_deniedThe person chose Cancel. Arrives in the redirect only.
shown on /connectenvironment_write_onlyThe environment hides values from apps.
shown on /connectproduction_lockedChanges to the environment need a second person's approval, so apps cannot be connected to it.
shown on /connectno_accessThe person cannot reveal values in the environment.
429rate_limitedToo many requests. retry-after gives the wait.

The full list is in Errors.

Security notes

  • S256 is the only challenge method. We refuse plain.
  • redirect_uri must match a registered address exactly. There are no wildcards and no prefix matches.
  • Codes are single use and last 60 seconds. We bind each to the app, the redirect address and the challenge.
  • Grants rotate on every use. Presenting a replaced grant revokes the connection.
  • A credential reads only the keys the person ticked, in one environment, and cannot write.
  • Values never appear in the redirect, the audit log or an error body.
  • These endpoints send no CORS headers. A browser extension calls them with host permissions for penv.cloud; a web page on another origin cannot.