SPA security baseline
Reduce the XSS and supply-chain paths that can expose browser-held authentication credentials.
Both session mode and bridge mode give JavaScript a bearer ID token so Convex can authenticate requests. The modes limit the consequences of token theft differently; neither makes arbitrary same-origin JavaScript safe. Treat every script allowed to run in your application as part of the authentication boundary.
This is a starting baseline, not a policy you can deploy without testing. Replace the example origins, inventory what your production build loads, roll restrictive headers out in report-only mode, and remove every source you do not need.
1. Content Security Policy
Serve CSP as an HTTP response header. A <meta http-equiv> policy cannot enforce
every directive below; frame-ancestors in particular is header-only. Start with
this policy, replacing the Convex and Logto hosts with the exact production values:
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self'; connect-src 'self' https://happy-otter-123.convex.cloud wss://happy-otter-123.convex.cloud https://auth.example.com https://api.example.com;- Keep both the
https://andwss://forms of the Convex deployment URL. Some browsers do not treatconnect-src 'self'as covering WebSockets. - Bridge mode calls the Logto endpoint from the browser, so include its exact origin.
Top-level OAuth navigation is separate from
connect-src, but discovery and token requests are not. Remove this source only when the browser makes no Logto requests. - If you mount the cookie transport
at a same-origin path,
'self'already covers it. If it uses another origin on the same site, add that exact HTTPS origin toconnect-src; thehttps://api.example.comsource above represents that optional endpoint. Remove it when you do not use cookie transport or it is same-origin. - Add narrow
img-src,font-src,style-src,worker-src, andframe-srcsources only when the production application needs them. Do not replace the policy with broadhttps:or wildcard sources just to silence a violation.
script-src 'self' omits 'unsafe-inline' and 'unsafe-eval' on purpose. A
production SPA build should load scripts from files. If SSR or a framework emits an
inline bootstrap script, authorize that script with a per-response nonce or a stable
hash; do not enable all inline script. Development HMR may require a looser policy,
which is not a reason to ship that policy to production.
frame-ancestors 'none' prevents other pages from framing the app. Replace it with a
small allowlist only if embedding is intentional. object-src 'none' removes legacy
plugin content, while base-uri 'self' prevents an injected <base> element from
retargeting relative URLs.
CSP reduces the ways an attacker can introduce or execute code. It does not sandbox code that the policy already allows. A compromised same-origin bundle, an allowed analytics tag, or script injected through an already-permitted source runs with the application's authority. At that point every JavaScript-readable token is available, and that script can use even an HttpOnly cookie for same-origin actions while the page is open. CSP is a boundary on what may execute, not a repair for trusted code that has become malicious.
See the MDN CSP guide for directive and deployment details.
2. Trusted Types
Trusted Types turn DOM injection sinks such as innerHTML from string-taking APIs
into typed boundaries. Begin in report-only mode so violations identify the code and
widgets you must remove or put behind a reviewed sanitizer:
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-to csp
Reporting-Endpoints: csp="https://app.example.com/csp-reports"After the reports are clean, append require-trusted-types-for 'script' to the
enforced CSP. If the app needs an HTML-producing policy, also use the
trusted-types directive to allow only named policies. Do not add a default policy
that returns its input unchanged; that converts enforcement into an annotation without
sanitization.
Ordinary React DOM rendering does not need raw HTML sinks, so much of a React app is compatible by construction. Audit the escape hatches:
dangerouslySetInnerHTML, especially values derived from users or a CMS;- DOM nodes obtained through refs and written with
innerHTML,outerHTML, orinsertAdjacentHTML; and - editors, analytics, tag managers, and other widgets that manipulate the DOM directly.
Current browsers support Trusted Types enforcement, but older clients may ignore it;
keep CSP and input-handling controls in place. React likewise cannot make direct DOM
writes by third-party code safe. The React DOM warning for
dangerouslySetInnerHTML
and the Trusted Types API guide
cover the two sides of this boundary.
3. Third-party scripts and dependencies
Third-party JavaScript is the most common way tokens get stolen from an otherwise well-maintained SPA. Code loaded from a tag manager, analytics vendor, support widget, or compromised npm dependency receives the same origin privileges as application code. CSP cannot distinguish its intended behavior from theft after it starts.
-
Minimize the number of scripts and owners with permission to change them. Treat tag-manager publish access as production deploy access, and review the container contents rather than only the loader snippet.
-
Pin static CDN scripts with Subresource Integrity and CORS:
<script src="https://cdn.example.com/widget-4.2.1.js" integrity="sha384-REPLACE_WITH_THE_PUBLISHED_HASH" crossorigin="anonymous" ></script>SRI pins exact bytes. It works for versioned, immutable resources; it does not protect a tag-manager URL whose contents change by design.
-
Commit the package-manager lockfile, review dependency and lockfile changes, and prefer releases with verifiable registry provenance. A lockfile makes installs repeatable; it does not prove the pinned package is trustworthy.
-
Audit analytics data collection, custom HTML tags, and dynamically injected scripts. Remove inactive tags and stale vendor accounts.
-
Prefer server-side integrations. When UI must be embedded, use a cross-origin sandboxed iframe with only the sandbox capabilities it needs instead of loading the vendor into the application origin.
See Subresource Integrity for hash generation and cross-origin requirements.
4. Minimize scopes and resources
Request only the identity fields and API permissions the application consumes. A stolen token cannot exercise a permission Logto never granted it.
The package always includes the OIDC scopes it needs: openid, profile,
offline_access, and email. Treat every value you add beyond those as a reviewed
permission:
- In session mode, you configure
scopesandresourcesonlogtoSessionApi(...). They are server-controlled; the browser cannot elevate them for an individual sign-in. - In bridge mode, pass only the extra
scopesandresourcesyou need toConvexLogtoProvider, and keep the matching Logto API resource and organization permissions as narrow.
Review scopes when you remove a feature as well as when you add one. Separating Logto applications by environment also prevents a development token from acquiring production authority.
5. Choose the right browser credential defense
The session-mode threat-model comparison explains the full trade-off. The practical distinction for this checklist is:
- Session mode keeps the Logto refresh token in the Convex component. By
default the browser has a short-lived ID token in
sessionStorageand a rotating session token inlocalStorage. The callback mints the first generation, and every successful refresh rotates it; the server retains only hashes for a bounded set of recent generations until their reuse-window expiry. - Bridge mode uses Logto's browser storage adapter. Its refresh token is in
localStorage, so same-origin script can copy the durable credential and use it according to the tenant's token lifetime and rotation policy.
That reduction matters, but the default session token remains JavaScript-readable. Choose one step-up when off-device exfiltration is in scope:
- The same-site cookie transport
moves the rotating token into a
__Host-HttpOnly, Secure, SameSite=Lax cookie and rolls its persistent lifetime on rotation. Prefer it when the app can expose the required same-site server endpoint. The bearer ID token remains readable by the Convex client, and same-origin malicious script can still act through the app. - Device binding keeps the token in JavaScript but requires proof from a non-extractable IndexedDB P-256 key on every refresh and revocation operation. Prefer it for a static SPA when the goal is to stop a copied token from working on another device. It does not stop script already executing on the original origin from using the key.
Cookie transport and device binding are mutually exclusive. Proof generation must sign the exact rotating token, while HttpOnly exists to make that token unavailable to JavaScript; cookie transport also removes the off-device token-copy path that device binding addresses. Safari's storage eviction behavior is an additional reason not to combine them. The session-mode guide documents the loud compatibility error and the trigger for re-evaluating native browser DBSC support.