[spec] Cookie read/write and local storage APIs#14731
[spec] Cookie read/write and local storage APIs#14731lukasmasuch wants to merge 2 commits intodevelopfrom
Conversation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Spec PR Validation✅ All checks passed! |
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
| **Note on `httponly`:** JavaScript cannot set HTTP-only cookies. If user specifies | ||
| `httponly=True`, log a warning. True HTTP-only cookies require ASGI middleware approach. |
There was a problem hiding this comment.
The note states that JavaScript cannot set HTTP-only cookies and suggests logging a warning, but the product spec example (line 144) shows httponly=True being used for session cookies. This creates a security vulnerability - users will think their session cookies are HTTP-only protected when they actually aren't.
The implementation should either:
- Raise an exception when
httponly=Trueto prevent false security expectations, or - Document clearly in the product spec that
httponly=Truecannot be used with this API and requires ASGI middleware instead
| **Note on `httponly`:** JavaScript cannot set HTTP-only cookies. If user specifies | |
| `httponly=True`, log a warning. True HTTP-only cookies require ASGI middleware approach. | |
| **Note on `httponly`:** JavaScript cannot set HTTP-only cookies. If user specifies | |
| `httponly=True`, raise a `ValueError` to prevent false security expectations. True HTTP-only cookies require ASGI middleware approach. | |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
There was a problem hiding this comment.
Done. Changed from logging a warning to raising a ValueError to prevent false security expectations. Also added a note that true HTTP-only cookies require server-side mechanisms.
There was a problem hiding this comment.
Pull request overview
Adds new product + tech specs under specs/ proposing two first-class client-side storage APIs (st.cookies and st.local_storage) to enable persistence across refreshes/sessions and cover common auth/preferences/state use cases.
Changes:
- Add a product spec + tech spec for a Cookie Read/Write API (
st.cookies). - Add a product spec + tech spec for a Local Storage API (
st.local_storage) including sync/namespace strategy and proposed proto/frontend/backend architecture. - Document API options, recommended designs, examples, and security considerations for both features.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 13 comments.
| File | Description |
|---|---|
| specs/2026-03-15-local-storage-api/tech-spec.md | Technical design for syncing localStorage to/from backend (proto, frontend handler, backend proxy, context caching, namespacing). |
| specs/2026-03-15-local-storage-api/product-spec.md | Proposed public API for st.local_storage, behaviors (timing, serialization, limits), examples, and checklist. |
| specs/2026-03-13-cookie-read-write-api/tech-spec.md | Technical design for cookie writes via ForwardMsg and backend proxy sketch; notes HttpOnly limitation. |
| specs/2026-03-13-cookie-read-write-api/product-spec.md | Proposed public API for st.cookies, behaviors, examples, security notes, and checklist. |
| ### Widget Binding (Related Feature) | ||
|
|
||
| The existing query-params binding spec (2026-01-06) includes plans for `bind="local-storage"`: | ||
|
|
||
| ```python | ||
| st.selectbox("Theme", ["light", "dark"], bind="local-storage", key="theme") | ||
| ``` |
There was a problem hiding this comment.
The widget-binding example uses bind="local-storage" and claims the existing 2026-01-06 query-param binding spec already includes this plan. In that spec, the proposed future extension is "localstorage" (no hyphen) and it’s framed as a possible later extension, not a committed plan. Align the enum spelling with the existing spec (or update that spec too) and soften the wording here if it’s not decided yet.
There was a problem hiding this comment.
Fixed. Updated the spelling to "localstorage" to match the existing query-params binding spec, and softened the wording to indicate it's a possible future extension rather than a committed plan.
| - **Client-only:** Data never sent to server (unlike cookies) | ||
| - **Same-origin:** Browser enforces same-origin policy | ||
| - **User-controlled:** Users can clear via browser dev tools | ||
| - **No sensitive data:** Docs will warn against storing passwords, tokens (use `st.cookies` | ||
| with `httponly=True` instead) |
There was a problem hiding this comment.
This section says localStorage data is “never sent to server”, but the tech spec proposes sending a localStorage snapshot to the backend on session connect so the Python API can read values. Please clarify the data-flow accurately (e.g., not sent with HTTP requests, but synced over the Streamlit websocket). Also, the guidance to use st.cookies with httponly=True conflicts with the cookie tech spec note that JS can’t set HttpOnly cookies; update the recommendation accordingly.
| - **Client-only:** Data never sent to server (unlike cookies) | |
| - **Same-origin:** Browser enforces same-origin policy | |
| - **User-controlled:** Users can clear via browser dev tools | |
| - **No sensitive data:** Docs will warn against storing passwords, tokens (use `st.cookies` | |
| with `httponly=True` instead) | |
| - **Browser-managed storage:** Data is stored in the browser and is not automatically | |
| attached to HTTP requests like cookies, but the current design syncs a snapshot to the | |
| backend over the Streamlit WebSocket so the Python API can read it | |
| - **Same-origin:** Browser enforces same-origin policy | |
| - **User-controlled:** Users can clear via browser dev tools | |
| - **No sensitive data:** Docs will warn against storing passwords or tokens in | |
| localStorage; sensitive values should use server-managed auth/session mechanisms (for | |
| example, server-set `HttpOnly` cookies), not client-managed storage |
There was a problem hiding this comment.
Fixed. Updated the security section to clarify that data is synced over the Streamlit WebSocket (not truly client-only), and removed the incorrect httponly=True recommendation. Now recommends server-set HttpOnly cookies via ASGI middleware for sensitive values.
| **Namespacing:** Keys are automatically namespaced per-app to prevent cross-app collisions: | ||
|
|
||
| ``` | ||
| st_local_storage_{app_id}_{key} | ||
| ``` | ||
|
|
||
| Where `app_id` is derived from the app's URL path. This prevents one app from reading/ | ||
| overwriting another app's data on the same domain. |
There was a problem hiding this comment.
The key namespacing format here (st_local_storage_{app_id}_{key}) doesn’t match the tech spec’s proposed prefix (st_ls_{hash(page_url_path)}_{user_key}) or the TS snippet’s st_ls_${getAppId()}_. Please pick one canonical on-disk key format and use it consistently across product + tech specs (including examples).
There was a problem hiding this comment.
Fixed. Aligned the namespacing format to st_ls_{app_id}_{key} in both product and tech specs, with clarification that app_id is derived from a hash of the URL path.
| - Individual values: Soft limit ~1MB (warn if exceeded) | ||
| - Total storage: ~5-10MB (browser-dependent) | ||
| - Raises `StreamlitAPIException` on `QuotaExceededError` |
There was a problem hiding this comment.
Size-limit/error behavior is inconsistent with the tech spec. Here it says the 1MB limit is a “soft limit” with a warning and that QuotaExceededError becomes a StreamlitAPIException, but the tech spec’s Python proxy raises immediately once the JSON value exceeds 1MB and the TS handler rethrows the DOMException. Please align the intended behavior (warn vs raise, and how frontend quota errors are surfaced to Python/users).
| - Individual values: Soft limit ~1MB (warn if exceeded) | |
| - Total storage: ~5-10MB (browser-dependent) | |
| - Raises `StreamlitAPIException` on `QuotaExceededError` | |
| - Individual values: Hard limit ~1MB per JSON-serialized value (writes raise immediately if exceeded) | |
| - Total storage: ~5-10MB (browser-dependent) | |
| - Browser quota failures surface as the underlying storage error (`QuotaExceededError` / `DOMException`), rather than being remapped to `StreamlitAPIException` |
There was a problem hiding this comment.
Fixed. Changed from soft limit (warn) to hard limit (raises immediately). Browser quota errors are now re-raised as-is rather than wrapped in StreamlitAPIException.
| | Item | Status | | ||
| |------------------------------|--------| |
There was a problem hiding this comment.
Spec template convention uses the checklist table header “✅ or comment” (not “Status”). To match the repo’s spec templates, please update the checklist table header/format accordingly.
| | Item | Status | | |
| |------------------------------|--------| | |
| | Item | ✅ or comment | | |
| |------------------------------|---------------| |
There was a problem hiding this comment.
Fixed. Updated to | Item | ✅ or comment | to match the repo's spec template convention.
| ### Behavior | ||
|
|
||
| **Timing:** Cookies are queued during script run and sent in the ForwardMsg. They become | ||
| readable on the **next** page load/rerun. | ||
|
|
||
| ```python | ||
| st.cookies["draft"] = "text" # Queued | ||
| st.rerun() # Sent to browser | ||
| # Next run: st.cookies["draft"] == "text" | ||
| ``` |
There was a problem hiding this comment.
This section claims cookies become readable on the “next page load/rerun”, but st.context.cookies (which this API is based on) is defined as cookies sent in the initial request / WebSocket handshake, and that context is cached for the lifetime of the connection. That means cookies set via document.cookie won’t be visible to the backend on a rerun without a full page refresh/reconnect (or an explicit cookie snapshot sent from the frontend). Please correct the timing semantics and update the example accordingly.
There was a problem hiding this comment.
Fixed. Clarified that cookies become readable only after a full page load or browser reconnect (not just st.rerun()), since st.context.cookies reflects cookies from the initial HTTP request/WebSocket handshake.
| ```python | ||
| session_id = st.cookies.get("session") | ||
| if not session_id: | ||
| session_id = secrets.token_urlsafe(32) | ||
| st.cookies.set("session", session_id, max_age=7*24*60*60, httponly=True) | ||
| ``` |
There was a problem hiding this comment.
The API surface and example suggest httponly=True is supported for st.cookies.set(...), but the tech spec explicitly notes JavaScript cannot set HttpOnly cookies (it can only be done server-side via response headers/middleware). This is a security-relevant mismatch: either remove httponly from this client-side API, make it a hard error, or clearly document that it’s ignored/unsupported and provide a separate server-side mechanism if needed.
There was a problem hiding this comment.
Fixed. Removed httponly=True from the example since JS cannot set HTTP-only cookies. Added a comment explaining that ASGI middleware is needed for truly HTTP-only session cookies.
| | Item | Status | | ||
| |------------------------------|--------| |
There was a problem hiding this comment.
Spec template convention uses the checklist table header “✅ or comment” (not “Status”). To match the repo’s spec templates, please update the checklist table header/format accordingly.
| | Item | Status | | |
| |------------------------------|--------| | |
| | Item | ✅ or comment | | |
| |------------------------------|----------------| |
There was a problem hiding this comment.
Fixed. Updated to | Item | ✅ or comment | to match the repo's spec template convention.
| max_age: int | timedelta | None = None, # None = session cookie | ||
| expires: datetime | None = None, | ||
| path: str = "/", | ||
| domain: str | None = None, | ||
| secure: bool = True, # HTTPS-only by default | ||
| httponly: bool = False, # JS-accessible by default | ||
| samesite: Literal["strict", "lax", "none"] = "lax", | ||
| ) -> None: |
There was a problem hiding this comment.
This parameter list includes httponly, but the tech spec notes HttpOnly cookies cannot be set from JavaScript. Keeping an httponly option on a client-side cookie-writing API is misleading unless it’s explicitly documented as unsupported/ignored or routed through a server-side response mechanism.
There was a problem hiding this comment.
Fixed. Removed the httponly parameter from st.cookies.set() entirely since JS cannot set HTTP-only cookies. Added a comment in the API section explaining this limitation and pointing to ASGI middleware for HTTP-only cookies.
| **Timing:** Same as `st.cookies` - values are queued during script run and sent to browser | ||
| via ForwardMsg. Readable on **next** rerun. | ||
|
|
||
| ```python | ||
| st.local_storage["draft"] = "my text" # Queued | ||
| st.rerun() # Sent to browser | ||
| # Next run: st.local_storage["draft"] == "my text" |
There was a problem hiding this comment.
This claims localStorage timing is “same as st.cookies” and that values are readable on the next rerun. Given that cookies from st.context.cookies are only from the initial request/handshake, and given the current tech-spec sketch doesn’t update the backend cache after writes, this timing statement is currently inconsistent with both designs. Please define the localStorage read-after-write semantics precisely (next rerun vs next full reload) and ensure the tech spec supports it.
| **Timing:** Same as `st.cookies` - values are queued during script run and sent to browser | |
| via ForwardMsg. Readable on **next** rerun. | |
| ```python | |
| st.local_storage["draft"] = "my text" # Queued | |
| st.rerun() # Sent to browser | |
| # Next run: st.local_storage["draft"] == "my text" | |
| **Timing:** Values written with `st.local_storage` are queued during the script run and | |
| sent to the browser via `ForwardMsg` after the run completes. They are **not** readable | |
| again in the same run, and they are **not guaranteed** to be readable on the next rerun | |
| of the same live session. With the design proposed here, read-after-write becomes visible | |
| only after a **full page reload / new browser connection**, when the backend rehydrates | |
| `st.local_storage` from the browser's localStorage during session initialization. | |
| ```python | |
| st.local_storage["draft"] = "my text" # Queued and sent to browser after this run | |
| st.rerun() # Does not make the new value readable yet | |
| # Same live session: st.local_storage.get("draft") may still be None | |
| # After a full browser reload / reconnect: | |
| # st.local_storage["draft"] == "my text" |
There was a problem hiding this comment.
Fixed. Updated the timing section to clarify that read-after-write becomes visible only after a full page reload / new browser connection, not on the next rerun. The design now includes optimistic cache updates for same-session reads.
- Remove httponly parameter from st.cookies API (JS cannot set HTTP-only cookies)
- Clarify timing semantics: values readable after page reload, not just rerun
- Align namespacing format between product and tech specs (st_ls_{app_id}_{key})
- Add optimistic cache updates to support read-after-write in same session
- Add set() method with expires_in parameter to tech spec
- Fix size limit behavior: hard limit 1MB, re-raise browser quota errors
- Fix widget binding spelling to match existing spec ("localstorage" not "local-storage")
- Update checklist table headers to match spec template convention
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
The AI review job failed to complete. Please check the workflow run for details. You can retry by adding the 'ai-review' label again or manually triggering the workflow. |
Describe your changes
Adds product and tech specs for two related client-side storage features:
st.cookies) — First-class API for writing browser cookies, enabling persistent client-side storage that survives page refreshes (~4KB limit per cookie)st.local_storage) — First-class API for browser localStorage with auto JSON serialization (~5-10MB capacity)Both specs include:
GitHub Issue Link (if applicable)
Testing Plan
Agent metrics