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 scope | Out of scope |
|---|---|
| Row-level security correctness in the shipped schema | Vulnerabilities in Supabase itself |
Credential handling in server.mjs | Compromise of your own machine or OS |
| Local route authorisation | A Supabase project configured differently from the shipped schema |
| Serve-time patch integrity | Browser vulnerabilities |
| Secrets never reaching the browser | Physical access to an unlocked device |
Credential handling #
| Credential | Exposure | Why |
|---|---|---|
SUPABASE_ANON_KEY | Sent to the browser | Public by design. Every request it makes is still filtered by row-level security. |
SUPABASE_SERVICE_ROLE_KEY | Server only | Bypasses RLS entirely. Read only inside server handlers, never injected into a page. |
SUPABASE_ACCESS_TOKEN | Server only | Management API token. Can run arbitrary SQL. |
ADMIN_SECRET | Server only | Unlocks the admin console. Also the HMAC key for the admin cookie. |
| User JWT | Browser localStorage | Required 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 #
- Passwords are handled by Supabase Auth. The server never stores or hashes one.
- Signup and login are rate limited per IP — 10 requests per 60 seconds.
- JWTs are refreshed by injected client code; the server verifies tokens rather than trusting them.
- Per-user localStorage and IndexedDB namespacing prevents two accounts on one device from reading each other.
Local route authorisation #
| Route | Authorisation |
|---|---|
/__admin/* | Requires ADMIN_MODE_READY plus a signed cookie or an admin Supabase session. |
/api/update-now | Admin 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. |
/__leaderboard | Caller’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.
| Item | Assessment |
|---|---|
POST /__errors is unauthenticated | Appends 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 SQL | Admin-gated, but effectively remote DDL. It exists so the patch runner can work around a browser CORS restriction. |
| Default Supabase credentials in source | server.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.0 | So 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-side | The app grants itself full plan access locally. Intentional for a self-hosted build, not a bypass of anyone else’s service. |
Refresh tokens in localStorage | Standard 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:
- Version from
/api/version. - Whether admin mode was enabled.
- Reproduction steps and the observed versus expected behaviour.
- No secrets —
isotope logsredacts them for you.