/ Docs Guides / Authentication Quickstart ← All guides
Authentication

Two auth models, one issuer, zero client-asserted tenancy.

Humans authenticate with OIDC/PKCE against Zitadel (https://zitadel.cloud.ig1.com) — browser flow on a workstation, device flow on a headless host. Machines and agents use phase-26 scoped API keys — a client_id/client_secret pair that mints short-lived Bearer tokens with client_credentials. Either way, every call ends up as Authorization: Bearer and the API resolves the project and permission tier server-side — no header a client sends can widen its scope.

0 · TLS trust — normally nothing to do

Since 2026-08-25 the public endpoints (*.cloud.ig1.com) serve publicly-trusted Let's Encrypt certificates — no CA to install, nothing to configure. This step matters only if you use the in-lab endpoints (*.10.57.8.64.nip.io), which serve the IG1 internal CA. For those, pull the CA certificate once per machine:

kubectl -n cert-manager get secret ig1-internal-ca-secret \
  -o jsonpath='{.data.ca\.crt}' | base64 -d > ig1-internal-ca.pem

Then point every tool at it:

No -k, no --insecure. The CA certificate is public material — distributing it weakens nothing. Skipping verification does.

1 · OIDC/PKCE — the human flow

Browser flow (workstation)

ig1 login starts a loopback listener on 127.0.0.1:8000 (fallback 18000), opens the system browser at the issuer's authorize endpoint, and exchanges the returned code with a PKCE S256 verifier. The client is a public native client — no secret, PKCE only. The requested scope carries the project-roles claim and the mandatory project audience (without the audience segment the API's introspection declares tokens inactive):

openid profile email offline_access \
  urn:zitadel:iam:org:project:roles \
  urn:zitadel:iam:org:project:id:385253743135817916:aud

The resulting tokens land in ~/.ig1/config.json (0600) under the current context. When the access token has under 30 s left, the CLI transparently runs the refresh_token grant — offline_access is what issues the refresh token — and persists the rotated pair (Zitadel rotates refresh tokens; the CLI keeps the newest). A login is verified immediately with a GET /v1/whoami before it is stored.

Device flow (headless host, RFC 8628)

ig1 login --device
# ig1: visit https://zitadel.cloud.ig1.com/device and enter code XXXX-XXXX

The CLI POSTs /oauth/v2/device_authorization, prints the user code and verification URI, then polls the token endpoint with the urn:ietf:params:oauth:grant-type:device_code grant — honouring authorization_pending, backing off on slow_down, and failing cleanly on expired_token / access_denied. The tokens it wins are stored exactly like the browser flow's.

2 · Scoped API keys — the machine flow (phase 26)

A scoped credential is a Zitadel machine user plus an entry in the API's dynamic credential store: a kind (api-key for scripts/CI/Terraform, mcp for agents), a tier, a label, the binding to your Keystone project, and an optional expiry (1–365 days).

Create

Three equivalent paths — portal (Security → API keys / Agent / MCP credentials → Create), REST, or CLI:

curl -s -X POST \
  https://api.cloud.ig1.com/v1/credentials \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"api-key","tier":1,"label":"ci-runner","expires_in_days":90}'

ig1 credential create --kind api-key --tier 1 --label ci-runner --expires-in 90
The client_secret and the pat are in the create response ONLY. They are never stored by IG1, never in the audit event, never retrievable afterwards — rotation means revoke + re-issue. (ig1 credential list shows the non-secret client_id only for credentials created by that CLI on that machine.)

Use

Paste-ready path (2026-08-12): the create response's pat is a Zitadel personal access token — a Bearer that works as-is until the credential is revoked or expires (no minting, no 12 h re-mint). It resolves project + tier through the same introspection path as a minted token.

Headless path: the pair mints a short-lived Bearer at the issuer's token endpoint — Basic-auth with client_id:client_secret, grant client_credentials, and the machine-user scope:

curl -s \
  -u "$IG1_CLIENT_ID:$IG1_CLIENT_SECRET" \
  -d grant_type=client_credentials \
  -d "scope=openid urn:zitadel:iam:org:project:roles urn:zitadel:iam:org:project:id:385253743135817916:aud" \
  https://zitadel.cloud.ig1.com/oauth/v2/token

The CLI does this for you: IG1_API_KEY=<id>:<secret> (env, never argv) or ig1 login --api-key, with a 5-minute per-credential token cache under ~/.ig1/token-cache/ (0600).

How scoping actually works (server-side, always)

  1. Every call is introspected against Zitadel (RFC 7662, 60 s cache per token hash).
  2. The introspected user id resolves through the credential store first — the credential's own project and tier — then the static tenant map, then admin roles.
  3. The OpenStack proxy stamps X-Project-Id toward Nova/Neutron/Cinder/Glance itself (admin callers deliberately carry none — an unscoped all-projects view).
  4. Client-supplied tenancy is dead: X-Tenant-Id has been ignored since phase 16. There is no header you can send to see another project.

3 · Tiers — the blast-radius contract

TierNameWhat it allowsWire policy (API proxy)
0read-onlylist/get everything in the projectGET, HEAD, OPTIONS
1operatetier 0 + create/update/scale/actions+ POST, PUT, PATCH
2destructivetier 1 + deletes+ DELETE

Static-map and admin-role callers resolve tier 2 (backward compatible). A credential can never outrank its creator: minting a tier-n credential requires holding tier n yourself. Start every machine credential at tier 0 and raise it only for the credentials that must act.

The one documented exception: detaching a volume (DELETE …/os-volume_attachments/{id}) is tier 1, not tier 2. Detach destroys nothing — the volume keeps its data, re-attaching restores the state, and Nova refuses to detach a root volume — so automation that attaches at tier 1 can undo its own work without holding a fully destructive credential. The exception table is enumerated and test-asserted (tests/test_tier_overrides.py); notably not in it: releasing a floating IP (the address returns to a shared pool) and deleting a security group (it can strand a workload) — both stay tier 2.

Agent-minted credentials are tighter still. The MCP server's create_credential tool refuses tier 2 outright and makes expiry mandatory, capped at 7 days (default: tier 0, 7 days) — destructive authority stays a human decision, and a forgotten agent key dies before anyone would have noticed it to revoke it. The API itself accepts 1–365 days; the cap is the agent surface's own guardrail.

4 · Rotation and revocation

ig1 credential revoke cred-6f2a1c9e4b7d48f0 --yes
# or: curl -X DELETE \
#   https://api.cloud.ig1.com/v1/credentials/cred-6f2a1c9e4b7d48f0 \
#   -H "Authorization: Bearer $TOKEN"

Revoke deactivates the Zitadel machine user first (in-flight tokens die at the next introspection), then marks the store entry revoked. A cross-tenant credential id answers 404 — existence is never leaked to other tenants. Per-credential last-used and call counts are at GET /v1/credentials/usage (ig1 credential list merges the same audit stream).

5 · The error contract (verbatim)

These strings are the contract — agents and scripts may match on them:

Statusdetail (verbatim)Meaning
401Missing or invalid Authorization headerno Bearer credentials on the call
401Token is not active / Token introspection faileddead or unverifiable token — re-mint / re-login
401Credential has been revoked / Credential has expiredthe API key is dead — re-issue
403Token carries no project roles; tenant access deniedthe user holds no role in the IG1 project
403No tenant assigned for user '…': no TENANT_MAP entry and no admin role. Ask an operator to map your Zitadel user to a Keystone project.no project binding — never a silent fallback
403Insufficient credential tier: POST requires tier 1, the caller's credential is tier 0. Tiers: 0 read-only, 1 operate (writes), 2 destructive (deletes).tier too low for the method (proxied OpenStack/k8s calls)
403Insufficient credential tier: this action requires tier 1, the caller's credential is tier 0. Tiers: 0 read-only, 1 operate (writes), 2 destructive (deletes). Create a higher-tier credential under POST /v1/credentials.tier too low for a first-class route (credentials, alarms, edge, …)
404Credential '…' not foundunknown OR cross-tenant id (existence never leaked)

Debug any auth state with GET /v1/whoami (ig1 whoami -o json) — it echoes the resolved user, roles, tenant (id, name, resolution source), all_projects, tier, and originating credential id.