Skip to content

Wiring Zitadel behind Traefik with issuer, JWKS and Login V2

The ingest 401 error did not come from the JWT itself, but from a small architectural mismatch. Issuer, JWKS and Host headers needed the same public view of Zitadel.

The ingest 401 error did not come from the JWT itself, but from a small architectural mismatch. Issuer, JWKS and Host headers needed the same public view of Zitadel.

Contents

Configuring Zitadel behind Traefik with issuer, JWKS and Login V2

The backend request initially looked like a classic token error.

401 Invalid or expired token

A message like this is deceptive because it can mean almost anything. An expired token. The wrong client. Incorrect scopes. The wrong signature. In my case, however, the cause ran deeper.

The token itself was not the problem. The problem was the view of Zitadel.

What iss and JWKS actually mean

A JWT carries an issuer claim. With Zitadel, it looks like this in my setup:

http://auth.localhost

This matters. The backend code checks not just the signature, but also whether the issuer exactly matches the configured Zitadel domain.

const { payload } = await jwtVerify(token, JWKS, {
  issuer: ZITADEL_DOMAIN,
});

The backend also needs Zitadel’s public keys through the JWKS endpoint.

http://auth.localhost/oauth/v2/keys

Both must align. The issuer and JWKS source must refer to the same public URL.

Why http://zitadel:8080 was wrong

I initially set the backend value to the internal Docker address.

ZITADEL_DOMAIN=http://zitadel:8080

At first glance, this seems logical because containers can naturally communicate internally using service names. For OIDC, however, it is the wrong view.

Zitadel always issues tokens with the public domain. If the backend code checks against the internal URL, the issuer no longer matches. At the same time, Zitadel does not return usable JWKS responses for zitadel:8080 because the Host header does not match its instance.

The result is the same error at two levels.

  1. The JWKS fetch fails.
  2. The issuer comparison fails.

The clean solution

I set the backend value to the public domain.

ZITADEL_DOMAIN=http://auth.localhost

This restores consistency. The token comes from auth.localhost. Signature verification queries auth.localhost/oauth/v2/keys. The issuer comparison matches.

To let the backend container resolve this domain internally, Traefik receives a Docker network alias.

traefik:
  networks:
    traefik_net:
      aliases:
        - auth.localhost

This is the important part. The backend container addresses auth.localhost. Docker resolves the name to Traefik, which forwards the request. From the backend code’s perspective, it remains the public Zitadel domain.

Login V2 and the base URI

The second issue was the Login V2 route. Zitadel constructs the login URL from the base URI and Login V2’s fixed path.

If the base URI points to http://localhost:9000, the result is a route that Login V2 does not recognize.

The correct base is:

ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI=http://auth.localhost/ui/v2/login

This produces the correct login route:

http://auth.localhost/ui/v2/login/login?authRequest=...

This is the route Traefik forwards to the login container.

Web or Native app

The extension app is currently configured in the portal as a Web app. This works.

Native is more typical for a Chrome Extension. In my case, however, this is not a functional blocker as long as the redirect uses chromiumapp.org and the token settings are correct.

I would therefore classify it as follows.

  1. Functionally, the current configuration is fine.
  2. Cleaner would be a Native app type later.
  3. Important first are the correct redirect and the JWT token type.

What I learned from issuer, JWKS and Login V2

OIDC is not just authentication. It is also a matter of perspective.

The browser sees the public domain. Internally, the backend container needs a different network view. Both views must still point to the same issuer. That was exactly the mistake.

Once this distinction is clear, the rest becomes easier.

Zitadel Login V2 in Docker Compose with Traefik and Host header routing

The original German image shows the authentication path with Login V2, Zitadel and Traefik. What matters is not only the container name, but the public domain that ultimately appears in the token.

All articles in the series

  1. Vision and system overview: Chrome Extension, RAG architecture and project background: Read article
  2. Building a RAG system: Qdrant, embeddings and cosine similarity in TypeScript: Read article
  3. AI provider abstraction: Ollama vs. OpenAI, interface design and no vendor lock-in: Read article
  4. Chrome Extension MV3: Three isolated runtime contexts, message passing and Strategy Pattern: Read article
  5. Docker Compose strategy: The override pattern, from local development to Azure: Read article
  6. Ollama locally vs. Docker: The decision and its consequences: Read article
  7. Ollama auto-pull entrypoint: Automated model setup at container startup: Read article
  8. tsconfig and Vite: Node16 vs. bundler, and why Vite has its own rules: Read article
  9. Load complete Instagram captions with MutationObserver: Read article
  10. Chrome Extension foundation with health indicator and retry queue: Read article
  11. Phase 2 features: Shadow DOM overlay, Tailwind v4 and duplicate detection: Read article
  12. Race condition in platform detection: How a UI event breaks Instagram detection: Read article
  13. Extracting PostId in two Instagram layouts: querySelector vs. ancestor traversal: Read article
  14. Capture a complete Instagram carousel with MutationObserver: Lazy loading, observer-before-click and timeout fallback: Read article
  15. Notes and tags when saving screenshots: Read article
  16. Instagram keyboard shortcuts block Chrome Extension input: Read article
  17. Lowercase normalization and duplicate detection in tag input: Read article
  18. Zitadel Login V2 in Docker Compose with three hidden errors: Read article
  19. PKCE OAuth in a Chrome MV3 extension: Read article
  20. React frontend with react-oidc-context and Zitadel: Read article
  21. Vite build-time environment variables in Docker: Read article
  22. Event-driven ingestion with BullMQ and Redis: Read article
  23. MinIO instead of Azurite: S3-compatible object storage locally and on Hetzner: Read article
  24. access_token, id_token and the Userinfo endpoint: Which belongs where: Read article
  25. Qdrant multi-tenancy with one collection per user: Read article
  26. When backend and frontend know different types: Read article
  27. Removing Zitadel bootstrap: Host header bug and manual setup: Read article
  28. Backend code review with six problems fixed before launch: Read article
  29. Traefik instead of NGINX: Reverse proxy for a growing Docker Compose stack: Read article
  30. Two-layer rate limiting: Traefik and express-rate-limit with Redis: Read article
  31. Implementing GDPR Article 17 correctly: Promise.allSettled and export batching: Read article
  32. Embedding model lock-in: Why mxbai-embed-large is a permanent production decision: Read article
  33. Docker volumes in production: Named volumes, bind mounts and the Hetzner volume approach: Read article
  34. Two vulnerabilities before launch: Redis without authentication and an exposed Qdrant admin port: Read article
  35. Traefik as the single entry point in the Docker Compose stack: Read article
  36. Connecting Zitadel behind Traefik with issuer, JWKS and Login V2: (this article)
  37. Fixing the frontend when the nginx healthcheck fails on localhost: Read article
  38. Observability for my Docker Compose stack with Bull Board and Dozzle: Read article
  39. Open the Qdrant dashboard securely with local Traefik and an SSH tunnel: Read article
  40. Diagnosis of chunking failures despite tokenization: Read article
  41. Why I set the chunk size to 1500 tokens: Read article
  42. Splitting the embedding workflow into clear steps: Read article
  43. Bringing chunking, storage and search back together: Read article
  44. Capture Intake as a separate module, less orchestration in the Express endpoint: Read article

Are you building a similar multi-service stack and deciding how to handle routing and TLS cleanly? Let us assess it together.

Related posts

Traefik instead of NGINX for a growing Docker Compose stack

With eight services in a Docker Compose stack, nginx.conf becomes a maintenance burden. Traefik reads service configuration directly from Docker labels, terminates TLS automatically through ACME and needs no separate configuration file. Why I switched and what the configuration looks like.

What should your portal do?

Describe your workflow. Together, we will identify the right next step.

Discuss your workflow