IsotopeAI / docs

Guides

Getting started Configuration Supabase setup Sync & backup Backup console Community

Reference

Architecture API reference Database CLI Android APK Admin console

Help

Troubleshooting FAQ Contributing Changelog

Legal

Security Privacy Terms License

Security

The threat model, how credentials are handled, and how to report a vulnerability. Written to be accurate rather than reassuring — the known weaknesses are listed too.

Last reviewed 2026-08-28

Threat model #

IsotopeAI is self-hosted. There is no shared backend, so the attack surface is your own machine and your own Supabase project.

In scopeOut of scope
Row-level security correctness in the shipped schemaVulnerabilities in Supabase itself
Credential handling in server.mjsCompromise of your own machine or OS
Local route authorisationA Supabase project configured differently from the shipped schema
Serve-time patch integrityBrowser vulnerabilities
Secrets never reaching the browserPhysical access to an unlocked device

Credential handling #

CredentialExposureWhy
SUPABASE_ANON_KEYSent to the browserPublic by design. Every request it makes is still filtered by row-level security.
SUPABASE_SERVICE_ROLE_KEYServer onlyBypasses RLS entirely. Read only inside server handlers, never injected into a page.
SUPABASE_ACCESS_TOKENServer onlyManagement API token. Can run arbitrary SQL.
ADMIN_SECRETServer onlyUnlocks the admin console. Also the HMAC key for the admin cookie.
User JWTBrowser localStorageRequired for the client to talk to Supabase directly.
// the one place the service key is allowed to be used
const useServiceKey = ADMIN_MODE_READY && isAdminAuthed(req);

Every other caller through /__supa/* is forwarded with their own Authorization header plus the anon key, so RLS still applies.

CI enforces thisThe build fails if .env becomes git-tracked, and scans every non-ignored file for service keys and access tokens.

Row-level security #

Enabled on all 42 tables, 153 policies. Three patterns cover nearly everything: own-row only, public-read with own-write, and membership-gated.

Membership checks never query group_members directly from a policy on that table — that recurses and Postgres aborts. They go through SECURITY DEFINER helpers with pinned search_path:

create or replace function public._is_group_member(gid uuid, uid uuid)
returns boolean language sql stable security definer
set search_path = public as $$
  select exists (
    select 1 from public.group_members
    where group_id = gid and user_id = uid
  );
$$;

A pinned search_path matters on a definer function: without it, a caller who can create objects in an earlier schema could shadow a table name and have the function operate on their object with the definer’s privileges.

# verify no definer function is missing a pinned search_path
npm run security:verify

Authentication #

Local route authorisation #

RouteAuthorisation
/__admin/*Requires ADMIN_MODE_READY plus a signed cookie or an admin Supabase session.
/api/update-nowAdmin cookie or loopback. Rejects any request carrying x-forwarded-*, since that indicates a proxy and a non-local caller.
/__auth/*Bearer token, verified server-side.
/__leaderboardCaller’s own JWT. Never the service key.
/__supa/*Caller’s JWT, escalated only for an authenticated admin.

Known weaknesses #

Listed deliberately. Each is a considered trade-off rather than an oversight.

ItemAssessment
POST /__errors is unauthenticatedAppends caller-supplied JSON to a local log. Capped at 1 MB per request but has no rate limit and no total size ceiling. Low risk on a loopback-only install; worth a cap if you expose the port.
POST /__admin/apply-sql executes arbitrary SQLAdmin-gated, but effectively remote DDL. It exists so the patch runner can work around a browser CORS restriction.
Default Supabase credentials in sourceserver.mjs carries a fallback project URL and anon key so a downloaded copy boots. Anon scope only, and documented in-file.
Server binds 0.0.0.0So a phone on your network can reach it. Anything else on that network can too. Prefer an SSH tunnel over exposing the port.
Premium gating is client-sideThe app grants itself full plan access locally. Intentional for a self-hosted build, not a bypass of anyone else’s service.
Refresh tokens in localStorageStandard for browser Supabase clients, but readable by any script running on the origin.

Reporting a vulnerability #

Open a GitHub issue for anything low risk. For something that could expose another person’s data, use GitHub’s private vulnerability reporting on the repository rather than a public issue.

Useful in a report: