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
kubectllogs 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.
kubectllogin 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.