Back to blog
#zitadel #auth #oidc #golang #security

Service-to-Service Auth with Zitadel: Machine Users and JWT Client Assertions

When I was building services for a hobby project that used Zitadel as the identity provider, I ran into a specific problem: one backend service needed to call Zitadel management APIs (creating users, reading org data) without any human involved. The usual username/password flow doesn’t make sense for a service. OAuth 2.0 solves this with the client_credentials grant, and Zitadel’s preferred flavor of it uses JWT client assertions.

Here’s the full setup.

The Problem: No Human in the Loop

Most OAuth flows start with a human authenticating. But for service-to-service calls, you want a bot-like identity that can authenticate itself cryptographically. Zitadel calls these machine users.

The flow works like this:

  1. Create a machine user in Zitadel.
  2. Generate an RSA key pair.
  3. Upload the public key to Zitadel, associated with the machine user.
  4. Your service uses the private key to sign a JWT assertion.
  5. Exchange that JWT for a real access token via the client_credentials grant.

Step 1: Create a Machine User in Zitadel

In the Zitadel console, create a new service user (machine user) under your organization. Note the user’s numeric ID — you’ll need it as the issuer claim in the JWT.

Step 2: Generate an RSA Key Pair

openssl genrsa -out system-user-1.pem 2048
openssl rsa -in system-user-1.pem -pubout -out system-user-1.pub

Step 3: Upload the Public Key to Zitadel

In the Zitadel console, go to the machine user and add the public key from system-user-1.pub. Zitadel will assign it a key ID.

Step 4: Create a JWT Assertion

The key2jwt tool from the Zitadel tools package does this for you:

go install github.com/zitadel/zitadel-tools/cmd/key2jwt@latest

~/go/bin/zitadel-tools key2jwt \
  --audience=https://zitadel.your-domain.com \
  --key=system-user-1.pem \
  --issuer=<machine-user-numeric-id>

This outputs a signed JWT. The issuer and subject are both the machine user ID. The audience is your Zitadel instance URL.

If you prefer Python, the same JWT can be constructed with pyjwt:

import jwt, time
from cryptography.hazmat.primitives import serialization

with open("system-user-1.pem", "rb") as f:
    private_key = serialization.load_pem_private_key(f.read(), password=None)

now = int(time.time())
payload = {
    "iss": "<machine-user-id>",
    "sub": "<machine-user-id>",
    "aud": ["https://zitadel.your-domain.com"],
    "iat": now,
    "exp": now + 3600,
}
token = jwt.encode(payload, private_key, algorithm="RS256")

Zitadel also exports key files in JSON format. Pass those directly:

~/go/bin/zitadel-tools key2jwt \
  --audience=https://zitadel.your-domain.com \
  --key=./296027699443400901.json \
  --issuer=295893464049057989

Step 5: Exchange for an Access Token

Send the JWT as a client_assertion to Zitadel’s token endpoint:

curl -X POST https://zitadel.your-domain.com/oauth/v2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
  --data-urlencode "assertion=<jwt-from-key2jwt>" \
  --data-urlencode "scope=openid profile email urn:zitadel:iam:org:project:id:<project-id>:aud"

If the key matches, Zitadel returns a short-lived access token your service can use for API calls.

The Membership Gotcha

I hit this error when trying to access resources in a different organization:

AUTHZ-cdgFk

The problem: a machine user belongs to a specific organization. If you try to call management APIs for a different org, Zitadel rejects the request with an authorization error, even if the access token is otherwise valid.

The fix is to grant the machine user membership in the target organization. In the Zitadel console, go to the target org’s members section and add the service user as an org owner (or whatever role it needs). After that, the API calls succeed.

Validating Tokens Locally

For incoming tokens from human users, you have two options: call Zitadel’s introspection endpoint on every request, or validate the JWT locally using Zitadel’s JWKS public keys.

The JWKS approach is faster. Fetch the public keys from https://zitadel.your-domain.com/oauth/v2/keys, cache them, and validate incoming JWTs without a network round-trip. The tradeoff is that you cannot detect forced logout in real time — a revoked token stays valid until it expires. For most use cases, this is an acceptable tradeoff.

GET https://zitadel.your-domain.com/oauth/v2/keys

Cache the JWKS (they change infrequently) and validate using any JWT library that supports RS256.