You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Surfaced while planning #708. This is the single blocker on browser support for every WASM surface, not just Supabase.
Answered 2026-08-26. Converted from a question to a decision record. The original asked whether clientKey is genuinely required under OIDC federation and whether it is secret. Both halves are now settled by reading the Rust, not inferred. The original text is preserved in the issue history.
The answer: required, and it is key material
Confirmed, not plausible. Three findings, each read from the deciding code.
1. The core requires it on every arm, including federation. In packages/protect-ffi/crates/protect-ffi/src/wasm.rs, new_client builds the key provider outside the match js_strategy — the match produces only auth, and .with_key_provider(...) is applied after it, so the federated arm pays it too. There is exactly one ZeroKMSBuilder in the wasm module and one newClient export, so there is no second path. The wasm arm of build_key_provider (client_options.rs) then errors unless both fields are present:
clientOpts.clientId and clientOpts.clientKey are required — this build has no profile store to fall back to
That string is present in the compiled protect_ffi_bg.wasm.
2. It is cryptographic key material, so browser exposure is a real key leak. The chain: SecretKey → ClientKey::from_bytes → KeySet::from_bytes → serde_cbor::from_slice into a ProxyKeySet of four permutations. DataKey::from_key_material then runs ProxyCipher::new(...).reencrypt::<16>(...) over the seed ZeroKMS returns. The material never leaves the process — that is the zero-knowledge property. Only the clientId UUID goes on the wire: all 14 ViturRequest structs were enumerated and none carries key material. The only two protocol structs containing client_key are CreatedClient and CreateClientResponse, both responses, at client creation.
3. It is not a type over-declaration. The native path does the same thing unconditionally. The only difference is the provider type — FallbackKeyProvider<Option<SecretKey>, ProfileStore> native, bare SecretKey on wasm. So the native TS type's optional clientKey? is explained by the env/profile fallback, not by the key being optional. packages/stack/src/types.ts:150 already says so: "(the clientKey is still required for encryption)".
Enforcement is in Rust, not in the types.resolveStrategy guards only the accessKey/strategy mutual exclusion and the accessKey arm's own fields; it never touches clientId/clientKey. So this is the expensive kind of fix, not a type edit.
Cite these, not ScopedCipher::init
An earlier draft of this finding said the client key is read "before and independently of any getToken()", citing ZeroKMSBuilder::build()andScopedCipher::init. The second half is false — ScopedCipher::init calls get_token() first (zerokms/mod.rs:685), then loads the keyset. The substance is decided more cleanly by two facts that involve no token at all:
cipherstash-client-0.42.2/src/zerokms/builder.rs:418 — let client_key = provider.0.client_key().await?
packages/protect-ffi/crates/protect-ffi/src/wasm.rs:563-569 — the unconditional .with_key_provider(...)
Federation authenticates the request. The key does the cryptography. They are not substitutes.
Severity: capability blocker, not a live vulnerability
Nothing shipping leaks a key. No browser export condition exists anywhere. No tracked .env, no long hex literal, and every CS_CLIENT_KEY= in the tree is a placeholder or a four-byte test fixture. Most decisively: noNEXT_PUBLIC_,VITE_orPUBLIC_prefixed client-key variable exists in packages/, skills/, examples/ or e2e/ — that prefix is what would actually put a key in a browser bundle.
The requirement lands in whatever process callsnewClient. Every documented target is server-side. So a browser-hosted WASM client is impossible without shipping the key, but that describes a design nobody has built, not anything shipping.
Reach is as claimed — all three wasm entry points are blocked at the same layer: @cipherstash/stack/wasm-inline, @cipherstash/stack-supabase/wasm-inline, and @cipherstash/protect-ffi's own wasm entries. protect-ffi's TS marks both fields optional, so a direct consumer is runtime-blocked but not type-blocked.
What remains, and where
Not fixable here. A browser-safe key shape — a per-session client key issued after federation, or proxy re-encryption behind a server component — is a protocol design job in cipherstash-suite, which is where cipherstash-client lives. This repo can only consume the result.
The threat model is already written down, contrary to the original text: skills/stash-auth/SKILL.md:187-198 states the requirement, names it key material, says it is never sent to any service, and marks CS_CLIENT_KEY as Secret. skills/stash-zerokms/SKILL.md:82 places it at level 3, "Application runtime only". Both landed in 8817cfb3 on 2026-07-30, three days after this issue was filed.
In-repo residue — three artifacts still advertised the WASM entry as browser-capable (examples/supabase-worker/README.md, that example's index.ts, packages/stack/tsup.config.ts). None ships to a customer's node_modules, but the example is linked by name from a shipped skill. Fixed on branch fix/804-browser-claims-docs, recovered from the unmerged #810 and re-based on main; the contract test came with it and needed porting to the protect-ffi 0.31 clientOpts shape before it passed.
Corrections to the original text
Type: clientId/clientKey are at packages/stack/src/wasm-inline.ts:223 and :225, with three union arms (accessKey, authStrategy, and a deprecated strategy arm) — not two.
Forwarding: :1553-1560, and credentials are now nested under clientOpts, not top-level. protect-ffi 0.31 denies unknown keys at the top level.
OidcFederationStrategy re-export: :145-148.
"the threat model has not been written down" — stale, see above.
Surfaced while planning #708. This is the single blocker on browser support for every WASM surface, not just Supabase.
The answer: required, and it is key material
Confirmed, not plausible. Three findings, each read from the deciding code.
1. The core requires it on every arm, including federation. In
packages/protect-ffi/crates/protect-ffi/src/wasm.rs,new_clientbuilds the key provider outside thematch js_strategy— the match produces onlyauth, and.with_key_provider(...)is applied after it, so the federated arm pays it too. There is exactly oneZeroKMSBuilderin the wasm module and onenewClientexport, so there is no second path. The wasm arm ofbuild_key_provider(client_options.rs) then errors unless both fields are present:That string is present in the compiled
protect_ffi_bg.wasm.2. It is cryptographic key material, so browser exposure is a real key leak. The chain:
SecretKey→ClientKey::from_bytes→KeySet::from_bytes→serde_cbor::from_sliceinto aProxyKeySetof four permutations.DataKey::from_key_materialthen runsProxyCipher::new(...).reencrypt::<16>(...)over the seed ZeroKMS returns. The material never leaves the process — that is the zero-knowledge property. Only theclientIdUUID goes on the wire: all 14ViturRequeststructs were enumerated and none carries key material. The only two protocol structs containingclient_keyareCreatedClientandCreateClientResponse, both responses, at client creation.3. It is not a type over-declaration. The native path does the same thing unconditionally. The only difference is the provider type —
FallbackKeyProvider<Option<SecretKey>, ProfileStore>native, bareSecretKeyon wasm. So the native TS type's optionalclientKey?is explained by the env/profile fallback, not by the key being optional.packages/stack/src/types.ts:150already says so: "(theclientKeyis still required for encryption)".Enforcement is in Rust, not in the types.
resolveStrategyguards only the accessKey/strategy mutual exclusion and the accessKey arm's own fields; it never touchesclientId/clientKey. So this is the expensive kind of fix, not a type edit.Cite these, not
ScopedCipher::initAn earlier draft of this finding said the client key is read "before and independently of any
getToken()", citingZeroKMSBuilder::build()andScopedCipher::init. The second half is false —ScopedCipher::initcallsget_token()first (zerokms/mod.rs:685), then loads the keyset. The substance is decided more cleanly by two facts that involve no token at all:cipherstash-client-0.42.2/src/zerokms/builder.rs:418—let client_key = provider.0.client_key().await?packages/protect-ffi/crates/protect-ffi/src/wasm.rs:563-569— the unconditional.with_key_provider(...)Federation authenticates the request. The key does the cryptography. They are not substitutes.
Severity: capability blocker, not a live vulnerability
Nothing shipping leaks a key. No
browserexport condition exists anywhere. No tracked.env, no long hex literal, and everyCS_CLIENT_KEY=in the tree is a placeholder or a four-byte test fixture. Most decisively: noNEXT_PUBLIC_,VITE_orPUBLIC_prefixed client-key variable exists inpackages/,skills/,examples/ore2e/— that prefix is what would actually put a key in a browser bundle.The requirement lands in whatever process calls
newClient. Every documented target is server-side. So a browser-hosted WASM client is impossible without shipping the key, but that describes a design nobody has built, not anything shipping.Reach is as claimed — all three wasm entry points are blocked at the same layer:
@cipherstash/stack/wasm-inline,@cipherstash/stack-supabase/wasm-inline, and@cipherstash/protect-ffi's own wasm entries. protect-ffi's TS marks both fields optional, so a direct consumer is runtime-blocked but not type-blocked.What remains, and where
Not fixable here. A browser-safe key shape — a per-session client key issued after federation, or proxy re-encryption behind a server component — is a protocol design job in
cipherstash-suite, which is wherecipherstash-clientlives. This repo can only consume the result.The threat model is already written down, contrary to the original text:
skills/stash-auth/SKILL.md:187-198states the requirement, names it key material, says it is never sent to any service, and marksCS_CLIENT_KEYas Secret.skills/stash-zerokms/SKILL.md:82places it at level 3, "Application runtime only". Both landed in8817cfb3on 2026-07-30, three days after this issue was filed.In-repo residue — three artifacts still advertised the WASM entry as browser-capable (
examples/supabase-worker/README.md, that example'sindex.ts,packages/stack/tsup.config.ts). None ships to a customer'snode_modules, but the example is linked by name from a shipped skill. Fixed on branchfix/804-browser-claims-docs, recovered from the unmerged #810 and re-based on main; the contract test came with it and needed porting to the protect-ffi 0.31clientOptsshape before it passed.Corrections to the original text
clientId/clientKeyare atpackages/stack/src/wasm-inline.ts:223and:225, with three union arms (accessKey, authStrategy, and a deprecatedstrategyarm) — not two.:1553-1560, and credentials are now nested underclientOpts, not top-level. protect-ffi 0.31 denies unknown keys at the top level.OidcFederationStrategyre-export::145-148.skills/stash-supabase/SKILL.mdbrowser guidance being "deliberately absent" — partly superseded; the edge entry now carries "server-side: it is not browser-safe (WasmClientConfig requires clientKey on every auth arm, so no browser-safe wasm client can be constructed #804)" in its own source comment, which ships compiled intodist/wasm-inline.d.ts.