SDK proxy
Why framework SDKs proxy Zitadel requests through the app origin.
Framework SDKs keep auth traffic same-origin where possible.
For Next.js and Nuxt, the SDK middleware proxies /__nextgen/* requests to the Zitadel backend, validates the session token, and redirects unauthenticated users away from protected routes. Zitadel issues encrypted session tokens, so the middleware checks each one against GET /sessions/me.
Only Zitadel's refusal signs a user out. When that check answers 401, or 404 for a session that is gone, the middleware clears the session cookie. When it gets no answer it can act on (Zitadel answers 5xx, does not answer in time, or cannot be reached), the middleware keeps the cookie and renders the request signed out, and a protected route answers 503 Service Unavailable instead of sending a user who may well be signed in to the login page. The next request checks again.
This keeps the app in control of browser-visible routes and cookies while Zitadel remains the backend API.
The default proxy path is:
/__nextgenThe login and logout components call the same-origin proxy path by default when configured through the SDK.
The project secret
Only one call in the browser flow needs a project credential: POST /sessions/exchange, which turns a completed sign-in into a session. The other calls need none, or only the session cookie the browser already holds. The proxy adds the project secret (ZITADEL_PROJECT_SECRET, which setup writes to .env.local) to that call as a bearer token, unless the call already carries an Authorization header, and to no other call. The secret stays on the server side of the proxy and never reaches the browser.
Sessions belong to one project
A session belongs to the project it was signed in to, while a browser sends a host's cookies to every app on it: on localhost, to every port, including the Console of a local server. So the server middleware counts a session as signed in only when it belongs to the app's own project. It reads the session with GET /sessions/me?project_id=…, which refuses a session of another project with 403 sess.project_mismatch, and it checks the project the answer names as well. A JWT must name the project in its aud claim. The logout components sign out with DELETE /sessions/me?project_id=… the same way, so signing out of an app never ends a session of another project. An app's own calls on its signed-in user take the same project_id: GET and PATCH /users/me, and GET /users/me/projects, /users/me/schema, /users/me/teams, /users/me/passkeys, /users/me/identity_links and /users/me/password. Without it, they answer for whichever session the cookie holds.
The project comes from the middleware's projectId option, else from ZITADEL_PROJECT_ID, which setup writes to .env.local, else from the framework's public project id variable. With none, no session counts as signed in. A session of another project reads as signed out, and its cookie stays: it is live in the app it belongs to.
One session cookie per project
Zitadel sets and reads one session cookie, __Host-zitadel_session. Over plain http to a loopback host, such as http://localhost:8080, it is zitadel_session instead: a browser keeps a cookie with the __Host- prefix only when it is Secure, and Safari drops a Secure cookie there. Under that name alone, the apps and the Console on one host would share a single cookie, and each sign-in would sign the others out. So the proxy keeps the app's session in a cookie named after its project, __Host-zitadel_session_<project id>, or zitadel_session_<project id> when the app is served over plain http to a loopback host. It turns the cookie Zitadel sets into the app's on its way to the browser, with the prefix and Secure as the app's own address needs, and sends the app's cookie to Zitadel as __Host-zitadel_session. It passes no other project's session cookie on. On localhost, signing in to an app leaves the Console of a local server and every other app signed in.
The __Host- prefix keeps other hosts from planting a session cookie: a browser accepts a cookie with it only from the host itself, over https. So where the app is served over https, the proxy and the middleware read no session cookie without the prefix.
On a server without platform bootstrap, the Console signs in to the app's project, so on localhost the session it keeps under Zitadel's name may be one of the app's. The first time the middleware or the session check of the widgets reads such a session, and it signs in a user of the app's project, it is copied to the app's cookie. Zitadel's cookie keeps it, because the Console still uses it. When the user next signs in or out through the app, the proxy clears Zitadel's cookie if it holds a session of the app's project, and on sign-out ends that session too, so it cannot sign the user back in. A session of another project in that cookie stays where it is. A response that sets a session cookie sends Cache-Control: private, no-store. With no project configured, the proxy passes the cookies through unchanged.
The session cookies Zitadel used before, __nextgen_session and __nextgen_session_<project id>, are neither read nor passed on: a browser that held one signs in again.
The dev proxies the CLI writes for single-page apps keep the session the same way, reading the project from ZITADEL_PROJECT_ID in .env.local. zitadel doctor --fix adds this to a dev proxy an earlier setup wrote, and zitadel doctor warns until then; it says the hooks must be added by hand to a dev proxy changed since. A dev proxy whose hooks an earlier setup wrote for the __nextgen_session cookies fails zitadel doctor until zitadel doctor --fix replaces them. In production, an SPA's own proxy passes Zitadel's session cookie through, which is fine there: a production host serves one project.
SPAs in production
Single-page apps (React, Vue, Angular, Solid, Svelte, Qwik) have no server of their own. In local development the CLI scaffolds a dev-server proxy for /__nextgen/* that adds the project secret to the session exchange. The CLI scaffolds nothing for production yet. The generated app sends no credential of its own, so in production it needs a same-origin proxy that adds the project secret to the session exchange, and to no other call. A plain rewrite cannot do that.
ADR 036 will remove the project-secret requirement from this browser-facing path — a browser-safe, origin-checked credential replaces it, with the project's origin allowlist as the enforcement boundary. A plain platform entry will then be enough: a vercel.json rewrite, a netlify.toml redirect, or a minimal Cloudflare worker (Cloudflare redirects cannot proxy external origins), with no secrets in platform env stores. The credential rollout and CLI scaffolding for these platform configs are tracked in zitadel/nextgen#560.
Was this helpful?