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.

Contents
- Configuring Zitadel behind Traefik with issuer, JWKS and Login V2
- What iss and JWKS actually mean
- Why http://zitadel:8080 was wrong
- The clean solution
- Login V2 and the base URI
- Web or Native app
- What I learned from issuer, JWKS and Login V2
- All articles in the series
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.localhostThis 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/keysBoth 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:8080At 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.
- The JWKS fetch fails.
- The issuer comparison fails.
The clean solution
I set the backend value to the public domain.
ZITADEL_DOMAIN=http://auth.localhostThis 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.localhostThis 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/loginThis 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.
- Functionally, the current configuration is fine.
- Cleaner would be a Native app type later.
- 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.

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



