API keys
An API secret is how code authenticates to the Management API. It is an OIDC client with the client credentials grant enabled: you get a client ID and a client secret, you exchange them for a short-lived access token, and you send that token as a bearer header.
API credentials and the Management API they authenticate to are experimental and unsupported. The API surface may still change. Use them against test environments rather than production workloads for now — the console shows the same notice on the API Credentials tab.
There is no long-lived "API key" header. If you have used APIs where a single static key goes in every request, the difference here is one extra step — a token exchange — in return for tokens that expire on their own.
1. Create the secret
In the console:
- Go to Teams and open the team (organization) that owns the clusters you want to manage.
- Open the API Credentials tab.
- Create credential. Give it a name, and select the organization roles it should hold.
- Copy the client secret.
It is not recoverable. Every later read of the credential returns ******** for the secret
field. If you lose it, revoke the credential and create another — there is no reset.
You need manage-organization and view-organization on the team to manage its credentials. An
organization may hold at most 10 API secrets; creating an eleventh returns a 400.
Choosing roles
The roles you grant decide what tokens from this secret can do. The API checks them per operation, so this is a real boundary and not a label:
| Intent | Grant | Result |
|---|---|---|
| Provision and manage clusters | manage-clusters (plus view) | Full read/write on the org's clusters |
| Read-only monitoring | view roles only | 200 on reads, 403 on writes |
| Billing automation | manage-billing | Payment methods, subscriptions, portal links |
You can only grant roles you hold yourself — the API rejects an attempt to escalate. Create a separate secret per consumer rather than one shared secret with every role; revoking one then does not break the others.
2. Exchange it for a token
Against the console host, not the API host:
export PHASETWO_CLIENT_ID='your-client-id'
export PHASETWO_CLIENT_SECRET='your-client-secret'
TOKEN=$(curl -s -X POST \
https://app.phasetwo.io/auth/realms/self/protocol/openid-connect/token \
-d grant_type=client_credentials \
-d client_id="$PHASETWO_CLIENT_ID" \
-d client_secret="$PHASETWO_CLIENT_SECRET" \
| jq -r .access_token)
The realm is self — the Phase Two control-plane realm, the one the console itself logs you
into. It is not one of your realms.
Why /auth/realms/self and not api.phasetwo.io?
The token endpoint belongs to the control-plane Keycloak, which is served under /auth on the
console host. The Management API is served on its own hostname. They are different services, so
they have different URLs — and a token request to api.phasetwo.io will not find the token
endpoint.
Put another way: app. issues the credential, api. accepts it.
3. Call the API
Against the API host, with the token as a bearer header:
curl -s https://api.phasetwo.io/v2/clusters \
-H "Authorization: Bearer $TOKEN" | jq
If that returns your clusters, you are set up. A useful next call is
org.list, which tells you the organization IDs you will need for
almost everything else:
curl -s https://api.phasetwo.io/v2/orgs \
-H "Authorization: Bearer $TOKEN" | jq '.[] | {id, name, roles}'
The roles array in that response is exactly what your secret was granted — a quick way to
confirm the permissions landed as intended.
Tokens expire
Access tokens are short-lived. There is no refresh token in the client credentials grant, which is the point: you re-run the exchange when you need a new one.
For anything long-running, fetch a token, use it, and re-fetch on a 401 rather than caching it for a fixed duration:
call_api() {
local path="$1"
local status
status=$(curl -s -o /tmp/p2.out -w '%{http_code}' \
"https://api.phasetwo.io/v2${path}" -H "Authorization: Bearer $TOKEN")
if [ "$status" = "401" ]; then
TOKEN=$(get_token) # re-exchange, then retry once
status=$(curl -s -o /tmp/p2.out -w '%{http_code}' \
"https://api.phasetwo.io/v2${path}" -H "Authorization: Bearer $TOKEN")
fi
[ "$status" -lt 300 ] || { echo "HTTP $status" >&2; cat /tmp/p2.out >&2; return 1; }
cat /tmp/p2.out
}
Storing the credential
Treat the client secret like any other deployment secret.
- CI: a masked secret in the runner's own store — GitHub Actions secrets, GitLab CI variables. Never in the workflow file.
- Terraform:
PHASETWO_CLIENT_ID/PHASETWO_CLIENT_SECRETenvironment variables, so it never lands in.tffiles or state. See the provider guide. - Local development: a
.envyour.gitignorealready covers, or your OS keychain.
A leaked secret is as powerful as the roles you gave it, which is the argument for narrow, per-consumer credentials.
Rotating and revoking
Rotation is create-then-revoke, and needs no downtime because the two credentials coexist:
- Create a second secret with the same roles.
- Deploy it wherever the old one is configured.
- Confirm traffic is using it.
- Revoke the old one in the console, or with
org.apiSecret.delete.
Revoking stops new tokens immediately. Tokens already issued keep working until they expire — revocation is not retroactive, so treat a compromised secret as compromised for the remaining token lifetime and check the audit log for what it did.
Troubleshooting
| Symptom | Cause |
|---|---|
401 from the token endpoint | Wrong client ID or secret, or a typo in the realm — it is self |
401 from api.phasetwo.io | Token expired, or you sent the client secret instead of the token |
403 on a write, 200 on reads | The secret lacks the organization role. This is working as designed |
404 on the token endpoint | You posted to api.phasetwo.io instead of app.phasetwo.io |
400 creating a secret | Already at the 10-per-organization cap, or you asked for a role you do not hold |
Secret shows ******** | Expected on every read after creation. Create a new one |
Next
- Automation recipes — provision a cluster, onboard a tenant, attach a domain
- Terraform provider — the declarative route
- API reference — all 82 endpoints