Identity

What runs

  • Zitadel is the identity provider for every app. Each app is its own project, with its own roles. (How Zitadel itself runs.)

  • AWS IAM Identity Center trusts Zitadel, so I log in to the AWS console and CLI with the same account, never as root.

  • The Kubernetes API trusts Zitadel for people, so kubectl logs in through the browser, like every other app.

  • Workload identity: pods in the home cluster assume AWS IAM roles with their own Kubernetes service-account token, not an AWS access key.

Why this choice

One login, access as a role. Granting or removing access happens in one place. Argo CD, for example, maps the Zitadel roles argocd-admin and argocd-readonly to its own admin and read-only roles. Anyone without a role gets nothing. An app with no login of its own, like the Tekton dashboard, gets an oauth2-proxy in front of it.

AWS access by role, too. Each aws-* role in Zitadel is an Identity Center group with a permission set and a session length:

Role Permission set Session
aws-admin AdministratorAccess 4 h
aws-readonly ReadOnlyAccess 8 h
aws-billing Billing 1 h

The CLI works the same way: aws sso login opens the Zitadel login in the browser, and the CLI gets credentials for the session. My laptop holds no AWS access key. The IAM user it used to call AWS with was deleted once the CLI worked through SSO.

kubectl by role, too. The apiserver accepts Zitadel's ID tokens directly, and kubelogin gets them in the browser, the way aws sso login does. It's a public client with PKCE, so no secret sits in anyone's kubeconfig, and a refresh token means one login lasts days. Two Zitadel roles map to Kubernetes' built-in roles:

Role Kubernetes role
k3s-admin cluster-admin
k3s-readonly view: reads everything except Secrets, changes nothing

The cluster sees each person by their own name, never the shared admin user. Names and groups from Zitadel get a zitadel: prefix, so a Zitadel role can never pose as one of Kubernetes' own groups.

kubectl ─▶ kubelogin ──browser──▶ Zitadel ──▶ ID token (who, and which roles)
kubectl ──ID token──▶ apiserver: checks issuer, audience, signature, verified email
                      RBAC: k3s-admin → cluster-admin, k3s-readonly → view

The apiserver reads this from a config file it reloads on its own, so a change to it needs no restart.

No long-lived keys for workloads. Kubernetes gives each pod a signed token for its service account, and the kubelet renews it on its own. AWS trusts the cluster as an identity provider, so a pod trades that token with AWS STS (AssumeRoleWithWebIdentity) for one hour of credentials. Each workload's IAM role trusts only its own service account. It's the same idea as EKS's IAM roles for service accounts, built for a cluster that isn't in AWS.

kubelet ──writes and renews──▶ token in the pod (1 h, for AWS only)
pod ──token──▶ AWS STS: checks issuer, audience, service account ──▶ 1 h credentials
STS checks the signature with the cluster's public keys, published in S3

AWS has to fetch the cluster's public signing keys, so they're copied to a small public S3 bucket. The home cluster doesn't have to be reachable from the internet, and nothing private is in the bucket.

Why the cluster's tokens, not Zitadel's. The first version used Zitadel: each workload logged in with a stored client secret and traded Zitadel's JWT with STS. It worked, and a test job proved it end to end. Then the database backups needed it, and two limits showed. The backup sidecar runs for weeks, but the token was fetched once and expired after 12 hours. And Zitadel's own database would have needed Zitadel running to back itself up, or to be restored. The cluster's own tokens fix both, and need no stored secret, so the Zitadel version was removed. People still log in through Zitadel; workloads don't.

Trade-offs and known limits

  • SAML for AWS. Identity Center accepts only SAML from an external identity provider, so AWS is Zitadel's one SAML app; the rest use OIDC.

  • No automatic user sync. Zitadel can't push users to Identity Center, so a script syncs users and group members after a role changes.

  • Break-glass: because every login goes through Zitadel, the AWS root user, protected by MFA, is the way in if the home cluster is down. It's never used day to day.

  • Break-glass for the cluster is the k3s admin kubeconfig, a client certificate the apiserver checks without Zitadel. It's kept offline, like the Sealed Secrets key. Unlike AWS root it has no MFA and can't be revoked, only expired or cut off by rotating the cluster's client CA.

  • kubectl login needs Zitadel reachable from the servers. The apiserver fetches Zitadel's keys through its public address, so with Zitadel or the internet down, break-glass is the way in. A cold start doesn't wait on it: tested with the issuer unreachable, the apiserver was ready in seconds and only the Zitadel login failed.

  • No high-availability address for the API yet. Each kubeconfig names one server; a virtual IP across all three is a follow-up.

  • Workload access lives in git, not Zitadel. A workload's role and the service accounts it trusts are listed in one file and applied by a script. Revoking access is a commit, not a click in Zitadel.

  • The signing key matters. If the cluster's service-account signing key changes, for example in a rebuild that doesn't restore it, AWS rejects every workload until the new public keys are published. Publishing is one command, and it's in the runbook.

  • Only pods. Something outside the cluster can't get a service-account token. Nothing needs one today.

Next

See the Roadmap.