fix: verify the tunnelled session token and keep it out of client payloads - #718
Conversation
The widgets read GET /sessions/me internally, but an embedding app's own
header had no documented way to the same answer and kept rendering
signed-out CTAs beside a live session (2026-08-02 Wicklore rerun).
- @zitadel/sdk-next/session: new entry with getSession(), a client-side
read of the same-origin {proxyPath}/sessions/me — works on any page,
no middleware-matcher requirement, returns the client-safe
ClientAuthResult (no token). 401/404/anonymous map to signed out;
other failures throw.
- Client-safe auth shapes lifted to @zitadel/sdk-core as single source;
sdk-nuxt re-exports unchanged, so useAuth() and getSession() return
the identical shape.
- Scaffold guidance (AGENTS.md managed section) + generated profile-page
comments now name each framework's session read path (Next getSession,
Nuxt useAuth, SPA raw proxy read).
…loads auth() decoded the x-nextgen-auth-token header without verification and treated any other non-empty value as middleware-validated — on routes outside the middleware matcher, a client-supplied header spoofed any identity. auth() now re-verifies everything it reads: JWTs via JWKS with the middleware's rules, opaque tokens against GET /sessions/me (per-render deduped, fail-closed), and opaque sessions gain the real identity instead of userId "unknown". NextgenProvider serialised the raw session token into the RSC flight payload when fed the documented `await auth()` result. It is now a shared component that strips to the client-safe ClientAuthResult before the value crosses the server→client boundary, matching sdk-nuxt's SSR-payload strip; useAuth() returns ClientAuthResult. Client components get a dedicated @zitadel/sdk-next/react entry; the root barrel is a server surface guarded by server-only. The package now builds as per-file tsc output because tsup's chunk splitting dropped "use client" off the shared context chunk — the published provider/hook entries could never have worked from a real consumer. demo-next now exercises the documented provider/useAuth pattern so it stays compiling in CI. isJwtShaped moves to @zitadel/sdk-core as the shared JWS/JWE detector.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 4586719 The changes in this PR will be included in the next version bump. This PR includes changesets to release 20 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
This PR hardens the Next.js SDK (@zitadel/sdk-next) against session-token spoofing and token leakage by (1) re-verifying the tunnelled auth header in auth() and (2) ensuring the raw session token never crosses the server→client boundary via the React provider/hook surface. It also aligns shared JWT/JWE shape detection into @zitadel/sdk-core, adds a dedicated client React entry point, and adjusts the sdk-next build to preserve "use client" directives reliably.
Changes:
- Rework
auth()to verify JWT-shaped headers via JWKS and validate opaque tokens viaGET /sessions/me, failing closed and returning real identity for opaque sessions. - Introduce
@zitadel/sdk-next/reactand a server-renderedNextgenProviderthat stripstokenbefore seeding client context;useAuth()now returnsClientAuthResult. - Switch sdk-next build from tsup bundling to per-file
tscemit to preserve"use client"directives; update docs, tests, and demo app usage accordingly.
Reviewed changes
Copilot reviewed 33 out of 34 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Removes tsup from the lockfile as sdk-next no longer uses it for builds. |
| packages/sdk-next/vitest.config.ts | Aliases server-only to a test stub so server modules can be imported under Vitest. |
| packages/sdk-next/tsconfig.build.json | Updates build config to emit declarations and resolve deps via built dist types (no @zitadel/source). |
| packages/sdk-next/src/useAuth.ts | Changes hook return type to ClientAuthResult and updates imports to .js ESM style. |
| packages/sdk-next/src/session.ts | Updates ESM type imports/exports to .js extensions. |
| packages/sdk-next/src/server.ts | Exposes AuthOptions alongside auth() from the /server entry. |
| packages/sdk-next/src/react.ts | Adds a dedicated /react entry exporting provider/hooks and types for client usage. |
| packages/sdk-next/src/provider.tsx | Adds server-rendered NextgenProvider that strips the raw token before passing state to client context. |
| packages/sdk-next/src/middleware.ts | Uses shared isJwtShaped (via ./lib/jwt.js) and removes local implementation. |
| packages/sdk-next/src/lib/jwt.ts | Re-exports isJwtShaped from sdk-core’s JWT utilities. |
| packages/sdk-next/src/index.ts | Updates root barrel exports to .js extensions and documents server-only intent. |
| packages/sdk-next/src/context.tsx | Refactors context carrier to accept an already-client-safe ClientAuthResult value. |
| packages/sdk-next/src/auth.ts | Makes auth() server-only and verifies/validates tunnelled tokens instead of trusting them. |
| packages/sdk-next/src/tests/useAuth.test.tsx | Adds regression coverage ensuring tokens are stripped before reaching client state; tests provider input shapes. |
| packages/sdk-next/src/tests/stubs/server-only.ts | Adds a no-op server-only module for tests. |
| packages/sdk-next/src/tests/lib/jwt.test.ts | Adds unit tests for isJwtShaped. |
| packages/sdk-next/src/tests/auth.test.ts | Expands auth() tests to cover forged JWTs, missing sub, expiry, opaque validation, and fail-closed behavior. |
| packages/sdk-next/README.md | Documents entry points, safe import paths, auth() verification behavior, and token-stripping provider pattern. |
| packages/sdk-next/package.json | Adds /react export, declares sideEffects for client.js and auth.js, and switches build to tsc. |
| packages/sdk-core/src/jwt.ts | Introduces shared isJwtShaped() helper in sdk-core. |
| packages/sdk-core/src/index.ts | Re-exports isJwtShaped from sdk-core’s public surface. |
| apps/demo-next/src/app/layout.tsx | Seeds client auth state via auth() + NextgenProvider (token stripped server-side). |
| apps/demo-next/src/app/admin/user-badge.tsx | Adds a client component that reads client-safe auth state via useAuth(). |
| apps/demo-next/src/app/admin/page.tsx | Displays the new user badge in the admin page header. |
| .changeset/verify-auth-header-strip-token.md | Adds release notes + version bumps for @zitadel/sdk-next and @zitadel/sdk-core. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
…g-lalande-218efe Resolution notes: took main's #717-final session.ts/session.test.ts/changeset (JSON-guarded 404, proxyPath normalization, unknown-vs-signed-out example), re-applied the .js-extension policy to session.ts, kept the tsc per-file build and /react wiring, preserved main's next>=15 peer floor.
Codex review round: P1: NextgenProvider was exported from the /react client entry while accepting the token-bearing auth() result — a "use client" wrapper (the common providers.tsx pattern) made the wrapper the RSC boundary and serialised the raw token before the strip ran. The provider now imports server-only directly and lives on /server (+ root); /react gains AuthContextProvider (ClientAuthResult-only) for client-seeded trees. The demo-next e2e happy path now asserts the raw session token appears nowhere in the server response for /admin. P2: both sdk-next validation passes (middleware opaque fallback and auth()) used jwksTimeoutMs for GET /sessions/me, silently ignoring the documented opaqueTokenTimeoutMs that sdk-nuxt already honours. Both now thread opaqueTokenTimeoutMs (default 5000ms); auth.test.ts proves the abort wiring with a signal-respecting fetch mock.
|
Addressed the Codex review round in c4e7f50: P1 (provider leak through a client wrapper) — accepted, fixed structurally. The shared-component strip was only safe when the provider rendered from a server module; a consumer's P2 ( Also refreshed the PR body (test count is 132 at head — the 127 was a pre-merge-round number, as flagged). 🤖 Addressed by Claude Code |
## Summary - normalize missing, expired, superseded, and rotated self-session cookies to the canonical `401 auth.unauthorized` contract for both `GET /sessions/me` and `DELETE /sessions/me`, with `Cache-Control: no-store` on every response path - keep the browser-facing Next.js `getSession` helper on the dedicated `@zitadel/sdk-next/session` subpath and require canonical 401/404 envelopes before reporting a signed-out state - attach project credentials only to the exact `POST /sessions/exchange` operation in Next.js, Nuxt, and generated Vite/Angular proxies while preserving caller-provided authorization - surgically migrate only fingerprinted legacy managed proxies, preserve target/rewrite/TLS options and surrounding comments, and make `zitadel doctor` warn instead of certifying unknown or deceptively marked proxy code - merge #718's token verification and provider hardening, resolving its export overlap without restoring `getSession` at the package root ## Validation - `moon run server:test` - `go test -c -tags postgres_integration ./internal/api/integration_test` (container-backed suite compiled locally) - focused CLI proxy/doctor tests: 73 passed - `moon run cli:test`: 105 files / 963 tests passed - `moon run cli:lint`, `moon run cli:typecheck` - merged-tree SDK checks: `sdk-core` (6 tests) and `sdk-next` (7 files / 144 tests), plus lint, typecheck, and build - merged-tree `demo-next` build/typecheck/lint and `demo-next-e2e` lint - `moon run workspace:check -- --only pack` - `moon run workspace:check -- --only release` - `moon run workspace:check -- --only journey` (Next, Nuxt, React, Vue, Angular, Solid, Svelte, and Qwik fresh-app browser journeys) - OpenAPI validation, generated-code checks, Go format/vet/tests, changeset status, and PR-title validation The all-workspace Node aggregate hit one unrelated console test timeout under concurrency; the exact console suite passed 8/8 when rerun in isolation. Postgres and Spanner integration phases could not run locally because Docker was not running, so GitHub Actions remains the source of truth for those container-backed lanes. ## Release notes / changeset Added `.changeset/harden-session-proxy-credentials.md` with patch releases for `@zitadel/cli`, `@zitadel/server`, `@zitadel/sdk-next`, and `@zitadel/sdk-nuxt`. The merged #718 changeset independently carries its release intent; Changesets resolves the combined branch outcome. ## Notes This is the security follow-up to #717. #718 is now merged and its provider/token-verification hardening is retained. Existing apps with a legacy managed Vite or Angular proxy should run `zitadel doctor --fix` after upgrading the CLI. Custom or unrecognized proxy implementations are deliberately left untouched and surfaced for manual review. The implementation was checked against ADR 005 (public runtime/private credentials), ADR 030 (canonical error envelopes), ADR 037 (server-authoritative sessions), and proposed ADR 036 (interim credential boundary). Restricting the confidential project secret to the exact handoff exchange is the immediate hardening step; the publishable-key design remains the target state. The first merged-tree `full-pr` run exposed that the integration test server bypassed the production `WithSessionStateNoStore` wrapper, so its expired-session assertion could not observe the header that production emits. The harness now mirrors that production middleware; the restarted Postgres lane is the runtime proof.
…ession-read plane (#722) ## Summary Amends ADR 036 (API credential planes) in place — status stays Proposed, with an `Amended: 2026-08-03` marker — with three decisions settled while hardening the sdk-next auth surface (#717/#718): - **Credential exposure contracts.** The browser-bundle litmus test generalises to a per-class contract table (publishable key, project secret, session token, handoff token): which surfaces each credential may appear on, and which it must never appear on. The session-token row makes the httpOnly contract explicit — *no server-side surface may re-materialise the token into browser-readable content* (HTML/DOM, serialised SSR/RSC payloads, client state, URLs, logs) — and cites the wired enforcement: sdk-nuxt's SSR-payload strip, sdk-next's server-only `NextgenProvider` strip, and the demo-next e2e leak guard (#718) as the template new SDK integrations are expected to carry. OIDC access/refresh tokens stay in ADR 037. - **Server-originated me-ops.** SSR session validation (`auth()`, the framework middlewares) is public-plane software acting for the human: publishable key for attribution + the user's session cookie as principal — never the project secret, preserving the zero-platform-secrets endgame for SSR. Origin-allowlist enforcement is scoped to credential-*establishing* operations (flow ops, public-plane handoff exchange, where it also derives the WebAuthn RP ID); cookie-principal reads authenticate by the cookie — a strictly stronger credential than an attacker-settable `Origin` — so server-originated calls with no browser-attested `Origin` pass. - **SSR session validation happens at the render layer.** The `x-nextgen-auth-token` tunnel is named for what it was — an unnamed intra-app credential channel, spoofable wherever the middleware didn't run. #718 closed the spoof by re-verifying the header; this amendment removes the channel: `auth()` reads the session cookie (and `Authorization` in Route Handlers) directly and validates once, works on every route, and the `matcher` constrains only redirects — aligning server reads with `getSession()`. Middleware keeps two jobs: the credential-free proxy and full-validation redirect gating on `protectedRoutes` only. The HMAC-attestation alternative is explicitly foreclosed: it would need a durable shared secret in exactly the layer this ADR empties of secrets. The amendment also records specced-vs-wired: #717's `getSession()` is the model's client half (shipped), #718's verification core and client-safe boundary are the server half (shipped behind the old transport); the remaining work is the transport swap, tunnel deletion, publishable-key attachment once the key exists, and the matcher-prose sweep. Neither PR is obsoleted — the delete is ~30 lines of transport plus prose. ## Validation Docs-only. PR title validated with `scripts/check-pr-title.mjs`. Table style matches the ADR's existing tables. ## Release notes / changeset No changeset required — no shipped behavior changed. ## Notes - One judgment call to sanity-check in review: middleware **retains full validation on `protectedRoutes`** (a structural-only check would render signed-out content instead of redirecting), accepting bounded double-validation there. The alternative — moving redirect gating to the render layer too — changes only that bullet. - The origin carve-out implies verifier work when publishable keys land: origin checks become per-operation-class, not blanket per-credential. - Follow-up code PR (cookie-reading `auth()` in sdk-next, tunnel deletion, prose sweep) is gated on this review; sdk-nuxt already validates every request and only needs publishable-key wiring.
Was stacked on #717 (merged — now targeting
main). Closes the two sdk-next hardening findings from the getSession session-state work, plus a Codex review round (provider client-boundary leak,opaqueTokenTimeoutMs).Summary
auth()verifies instead of trusts. It previouslydecodeJwt()ed thex-nextgen-auth-tokenheader without signature verification and treated any other non-empty value as a middleware-validated opaque token — so on any route outside the middlewarematcher(the scaffolded matcher covers only the proxy path and protected routes), a client-supplied header spoofed any identity, and evenx-nextgen-auth-token: garbagereturnedisAuthenticated: true.auth()now re-verifies every value it reads: JWT-shaped tokens get fullverifyJwt(JWKS signature,iss,exp, alg/typ allow-lists — same rules and defaults as the middleware, in-process key cache), opaque tokens are validated againstGET /sessions/me(Reactcache()-deduplicated per render pass, fail-closed with aconsole.warnthat distinguishes forged headers from JWKS/backend reachability problems). Opaque sessions now also return the real identity from the backend response instead ofuserId: "unknown". New optionalAuthOptions(aPickof the middleware options) for apps that customise verification; the docstring now states the matcher precondition instead of claiming the middleware always validated the header.NextgenProviderstrips the token server-side — and is server-only. The README's documented pattern (await auth()into the provider) serialised the raw session token into the RSC flight payload, readable by any client script. The provider now converts to the client-safeClientAuthResult(from feat: supported session-state read for the embedding app's own chrome #717, explicit field pick) before the value crosses the server→client boundary. Per the Codex P1 finding, the provider is additionally guarded withserver-onlyand exported from@zitadel/sdk-next/server(+ root), not/react: wrapping it in a"use client"file (the commonproviders.tsxpattern) would make the wrapper the boundary and serialise the still-unstripped prop — that wrapper is now a build error instead of a silent leak. Client-seeded trees (e.g. fromgetSession()) use the newAuthContextProvideron/react, which only accepts the token-less shape.useAuth()returnsClientAuthResult, matching sdk-nuxt andgetSession().@zitadel/sdk-next/reactentry (useAuth,AuthContextProvider, client-safe types);auth.tsimportsserver-only(already a dependency, previously unwired), so pullingauth()into a"use client"graph fails the build with an import trace. README gains an entry-point table;sideEffectsdeclared (client.jsfor element registration,auth.js/provider.jsto keep the guard imports unprunable).opaqueTokenTimeoutMshonoured (Codex P2 — ran deeper than reported): the option already existed in sdk-core'sNextgenMiddlewareOptionsand sdk-nuxt honours it, but both sdk-next validation passes coupled theGET /sessions/metimeout tojwksTimeoutMs— the middleware's opaque fallback (pre-existing bug) and the newauth()(which copied it faithfully). Both now threadopaqueTokenTimeoutMs(default 5000 ms), it's part ofAuthOptions, and the README options table documents it.createContextmodule into a shared chunk without its"use client"directive — the published provider/hook entries could never have worked from a real consumer (nothing in-repo exercised them, which is how it survived). The package now builds with per-filetscemit like sdk-core (directives preserved per module, dep types resolved via built dist by neutralising the@zitadel/sourcecondition), relative imports carry.jsextensions, and demo-next now rendersNextgenProvider+ auseAuthbadge so the documented pattern compiles in CI forever.isJwtShapedmoves to@zitadel/sdk-core(shared JWS/JWE structural detection; the middleware imports it from there).Validation
moon run sdk-core:{lint,typecheck,build,test} sdk-next:{lint,typecheck,build,test} demo-next:{lint,typecheck,build} demo-next-e2e:lint— all green (sdk-next: 132 tests at head; earlier counts in review threads reflect older heads).subrejected (previously authenticated via the opaque fallthrough), expired rejected, garbage opaque header rejected via mocked 401, backend-confirmed opaque token authenticates with real identity, anonymous/500/unreachable fail closed, andopaqueTokenTimeoutMsproven with a signal-respecting fetch mock (abort actually reaches the request). Provider tests assert the context value contains notokenkey for every accepted input shape;AuthContextProvidercovered for client seeding.authfrom the root barrel in a"use client"page fails the build with the full import trace intodist/auth.js; the Codex P1 repro — a"use client"wrapper aroundNextgenProvider— fails with'server-only' cannot be imported from a Client Component module; the committed subpath-based provider/badge usage builds clean.demo-next-e2e/src/auth.spec.tsnow asserts the raw session-cookie value appears nowhere in the server's/adminresponse (HTML + inlined RSC flight payload) — a durable leak detector for this class. Lint/typecheck-verified; not executed locally (port 8080 is held by a live localnextgenserver and the e2e web-server config pins that port; the suite is opt-in local). Worth a run wherever 8080 is free.dist/inspected:"use client"headscontext.js/useAuth.js,provider.jsopens withimport "server-only"(shared-render deliberately removed),auth.jslikewise.Release notes / changeset
.changeset/verify-auth-header-strip-token.md—@zitadel/sdk-nextminor,@zitadel/sdk-coreminor. Security hardening plus surface changes (useAuth()return shape, new/reactentry,NextgenProviderserver-only, root barrel build-errors in client graphs, opaque timeout no longer followsjwksTimeoutMs).Notes
auth()'s re-verification adds ~one cached-JWKS signature check for JWTs, or one/sessions/meround-trip per render pass for opaque tokens (per-rendercache()dedupe). If that round-trip ever matters, an HMAC attestation minted by the middleware (e.g. keyed offZITADEL_PROJECT_SECRET) could skip it — deliberately out of scope here.auth()reading the__nextgen_sessioncookie directly and dropping the matcher precondition entirely — but that contradicts the just-landed feat: supported session-state read for the embedding app's own chrome #717 guidance (which routes app chrome togetSession()), so it's flagged rather than done.useAuth) happen to compile under Turbopack's per-export tree shaking; that's bundler-dependent, so the README documents/reactand/sessionas the supported client imports rather than blessing it.