Skip to content

fix: verify the tunnelled session token and keep it out of client payloads - #718

Merged
fforootd merged 5 commits into
mainfrom
claude/exciting-lalande-218efe
Aug 3, 2026
Merged

fix: verify the tunnelled session token and keep it out of client payloads#718
fforootd merged 5 commits into
mainfrom
claude/exciting-lalande-218efe

Conversation

@fforootd

@fforootd fforootd commented Aug 2, 2026

Copy link
Copy Markdown
Member

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 previously decodeJwt()ed the x-nextgen-auth-token header without signature verification and treated any other non-empty value as a middleware-validated opaque token — so on any route outside the middleware matcher (the scaffolded matcher covers only the proxy path and protected routes), a client-supplied header spoofed any identity, and even x-nextgen-auth-token: garbage returned isAuthenticated: true. auth() now re-verifies every value it reads: JWT-shaped tokens get full verifyJwt (JWKS signature, iss, exp, alg/typ allow-lists — same rules and defaults as the middleware, in-process key cache), opaque tokens are validated against GET /sessions/me (React cache()-deduplicated per render pass, fail-closed with a console.warn that distinguishes forged headers from JWKS/backend reachability problems). Opaque sessions now also return the real identity from the backend response instead of userId: "unknown". New optional AuthOptions (a Pick of the middleware options) for apps that customise verification; the docstring now states the matcher precondition instead of claiming the middleware always validated the header.
  • NextgenProvider strips 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-safe ClientAuthResult (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 with server-only and exported from @zitadel/sdk-next/server (+ root), not /react: wrapping it in a "use client" file (the common providers.tsx pattern) 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. from getSession()) use the new AuthContextProvider on /react, which only accepts the token-less shape. useAuth() returns ClientAuthResult, matching sdk-nuxt and getSession().
  • Client entry + root-barrel guard. New @zitadel/sdk-next/react entry (useAuth, AuthContextProvider, client-safe types); auth.ts imports server-only (already a dependency, previously unwired), so pulling auth() into a "use client" graph fails the build with an import trace. README gains an entry-point table; sideEffects declared (client.js for element registration, auth.js/provider.js to keep the guard imports unprunable).
  • opaqueTokenTimeoutMs honoured (Codex P2 — ran deeper than reported): the option already existed in sdk-core's NextgenMiddlewareOptions and sdk-nuxt honours it, but both sdk-next validation passes coupled the GET /sessions/me timeout to jwksTimeoutMs — the middleware's opaque fallback (pre-existing bug) and the new auth() (which copied it faithfully). Both now thread opaqueTokenTimeoutMs (default 5000 ms), it's part of AuthOptions, and the README options table documents it.
  • Build correctness (found while wiring the above): tsup's chunk splitting hoisted the compiled createContext module 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-file tsc emit like sdk-core (directives preserved per module, dep types resolved via built dist by neutralising the @zitadel/source condition), relative imports carry .js extensions, and demo-next now renders NextgenProvider + a useAuth badge so the documented pattern compiles in CI forever.
  • isJwtShaped moves 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).
  • New regression tests use real RSA-signed JWTs against a mocked JWKS: forged/unsigned JWT rejected, signed-without-sub rejected (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, and opaqueTokenTimeoutMs proven with a signal-respecting fetch mock (abort actually reaches the request). Provider tests assert the context value contains no token key for every accepted input shape; AuthContextProvider covered for client seeding.
  • Compile-guard checks in demo-next (temporary routes, since removed): importing auth from the root barrel in a "use client" page fails the build with the full import trace into dist/auth.js; the Codex P1 repro — a "use client" wrapper around NextgenProvider — 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.ts now asserts the raw session-cookie value appears nowhere in the server's /admin response (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 local nextgen server 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" heads context.js/useAuth.js, provider.js opens with import "server-only" (shared-render deliberately removed), auth.js likewise.

Release notes / changeset

.changeset/verify-auth-header-strip-token.md@zitadel/sdk-next minor, @zitadel/sdk-core minor. Security hardening plus surface changes (useAuth() return shape, new /react entry, NextgenProvider server-only, root barrel build-errors in client graphs, opaque timeout no longer follows jwksTimeoutMs).

Notes

  • Defense-in-depth cost: on matcher-covered routes the middleware has already validated the token, so auth()'s re-verification adds ~one cached-JWKS signature check for JWTs, or one /sessions/me round-trip per render pass for opaque tokens (per-render cache() dedupe). If that round-trip ever matters, an HMAC attestation minted by the middleware (e.g. keyed off ZITADEL_PROJECT_SECRET) could skip it — deliberately out of scope here.
  • A further evolution would be auth() reading the __nextgen_session cookie 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 to getSession()), so it's flagged rather than done.
  • Root-barrel client imports of client exports (useAuth) happen to compile under Turbopack's per-export tree shaking; that's bundler-dependent, so the README documents /react and /session as the supported client imports rather than blessing it.

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.
Copilot AI review requested due to automatic review settings August 2, 2026 18:03
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nextgen Ready Ready Preview Aug 3, 2026 8:00am
nextgen-docs Ready Ready Preview Aug 3, 2026 8:00am
nextgen-mock-zitadel Ready Ready Preview Aug 3, 2026 8:00am

Request Review

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

🦋 Changeset detected

Latest commit: 4586719

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@zitadel/sdk-next Minor
@zitadel/sdk-core Minor
@zitadel/cli Minor
@zitadel/sdk-angular Minor
@zitadel/sdk-nuxt Minor
@zitadel/sdk-qwik Minor
@zitadel/sdk-react Minor
@zitadel/sdk-solid Minor
@zitadel/sdk-svelte Minor
@zitadel/sdk-vue Minor
@zitadel/server Minor
@zitadel/server-linux-x64 Minor
@zitadel/server-linux-arm64 Minor
@zitadel/server-darwin-x64 Minor
@zitadel/server-darwin-arm64 Minor
@zitadel/server-win32-x64 Minor
@zitadel/api Minor
@zitadel/config Minor
@zitadel/components Minor
@zitadel/testing Minor

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

Base automatically changed from claude/bold-liskov-1114cb to main August 2, 2026 18:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via GET /sessions/me, failing closed and returning real identity for opaque sessions.
  • Introduce @zitadel/sdk-next/react and a server-rendered NextgenProvider that strips token before seeding client context; useAuth() now returns ClientAuthResult.
  • Switch sdk-next build from tsup bundling to per-file tsc emit 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.
@fforootd

fforootd commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

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 "use client" wrapper made the wrapper the RSC boundary and serialised the raw session prop before the strip ran. NextgenProvider now imports server-only directly and lives on @zitadel/sdk-next/server (+ root) — the reproduced wrapper pattern now fails the build with 'server-only' cannot be imported from a Client Component module (verified against demo-next). /react keeps useAuth and gains AuthContextProvider (accepts only the token-less ClientAuthResult) for client-seeded trees. Per the test request, demo-next-e2e/src/auth.spec.ts now asserts the raw session-cookie value appears nowhere in the /admin response (HTML + inlined flight payload); it's lint/typecheck-verified but awaits an e2e run on a machine where port 8080 is free.

P2 (opaqueTokenTimeoutMs ignored) — accepted; it ran one layer deeper. The option already exists in sdk-core and sdk-nuxt honours it, but the sdk-next middleware's opaque fallback also coupled GET /sessions/me to jwksTimeoutMs (pre-existing; auth() had copied it). Both sdk-next passes now thread opaqueTokenTimeoutMs (default 5000 ms), it's part of AuthOptions, documented in the README options table, and covered by a signal-respecting fetch-mock test that proves the abort actually reaches the request.

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

fforootd added a commit that referenced this pull request Aug 3, 2026
## 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.
fforootd added a commit that referenced this pull request Aug 3, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants