App auth wiring
This page is the single source of truth for how a hosted Turbobase app authenticates a user, and wires supabase-js so that PostgREST reads and Realtime channels carry the user’s role into RLS. Every app template links here instead of inlining the auth boilerplate per prompt.
If you are writing a new template prompt, say “Wire auth per https://docs.turbobase.io/build-apps/auth/” and skip the inline copy.
Your app is served same-origin — there is no sign-in page to build
Section titled “Your app is served same-origin — there is no sign-in page to build”A hosted app is a static SPA that Turbobase serves from your instance at https://<slug>.turbobase.app/a/<app> — the same origin as the instance’s API, PostgREST, and Realtime. That single fact removes the entire auth surface a cross-origin app would have to carry (a sign-in page, an origin allowlist, a single-use sign-in code, a server-side redemption route, CORS). None of it exists here.
Because the app is same-origin:
- It inherits the instance session. When the browser loads
/a/<app>, it already carries the instance’sturbobase_session(HttpOnly) cookie. If the visitor is not signed in, the serving route 302s them to/loginon the instance before your SPA ever loads. So your app renders only for authenticated users — it has no sign-in page, no sign-in form, no/loginroute, no guard/redirect logic of its own. - Reads and Realtime go straight to PostgREST, same-origin, over supabase-js.
- Writes go straight to the write boundary (
POST /v1/data/:recordType), same-origin — the browser attaches theturbobase_sessioncookie automatically. No Bearer token, no proxy route handler.
Browser → GET /a/<app>/… (served by the instance) ↓ no session cookie? 302 → /login (the instance's own sign-in page, same origin) user signs in on the instance ↓ session cookie is now set, first-party GET /a/<app>/… your SPA loads, already authenticated ↓ reads → GET /rest/v1/… (supabase-js, same-origin) writes → POST /v1/data/… (fetch, credentials:'same-origin') realtime → wss same-originThe only cookie your code touches is tb_jwt — a non-HttpOnly mirror of the session JWT that the instance sets alongside turbobase_session at sign-in. supabase-js runs in the browser and can’t read HttpOnly cookies, so it reads tb_jwt for the apikey/Authorization value. Same JWT, readable cookie name.
What an app can reach — read this before you install one
Section titled “What an app can reach — read this before you install one”An app runs with the full authority of the signed-in user. It is served same-origin and reads that user’s own session JWT, so to PostgREST, Realtime and the write boundary it is that user.
Concretely, an app you install can:
- read every table the user’s RLS allows — not only the tables its manifest declares;
- subscribe to every table in the realtime publication, on the same terms;
- act with the user’s role. If the user is an admin, the app is an admin — an “admin-only” RLS policy is not a barrier between your data and app code.
The manifest is not an authorization boundary. It describes what an app intends to use; it does not constrain what the app can reach at runtime. Treat installing an app as granting it that user’s access, and install only apps you would trust with the account.
This holds for the manifest’s roles block too, and it is worth being explicit
because the block looks like it grants something. Deploying a NEW role creates
a role row — nothing more. The role has no holders until an admin assigns it,
so an app cannot widen its own access, or anyone else’s, by declaring a role it
needs. appRole is refused rather than accepted-and-ignored, because admin is
assigned, never authored.
Once an admin HAS assigned a declared role, a redeploy does change what its
holders can reach, within limits an admin can see. It can add capabilities and
never removes one — and never re-adds one an admin removed, nor a wildcard that
would grant it again, until an admin grants it back. It can set the role’s scopes — narrow its rows with a
restriction, or grant it a Slack channel’s history with content — and it can
take back only what the same app put there: a channel an admin added stays,
even if the app later declares the same channel (finding it already on the role,
the deploy adopts it and does not own it); a channel an admin removed is never
re-added by a deploy — not while the app keeps declaring it, and not after the
app stopped declaring it, dropped the role, or was deleted and reinstalled — until
an admin puts it back; a restriction the app wrote and an
admin then changed keeps the admin’s value (and if that value is wider, any
capabilities the deploy would add to the role are refused); and a restriction
that would overwrite an admin’s or another app’s is refused, failing the
deploy. The deploy reports every one of those changes, and so does
turbobase apps plan when it is told which app it is planning (--app <slug>).
The result is on Compliance › Users › Roles (/admin/roles) for an admin to
read and undo.
One of those grants reaches further than Slack itself would: a holder of a role
whose content lists a channel reads that channel’s history even if they are
not a member of it, or not a Slack user at all — private channels included.
That is the point of it, and it is bounded tightly: exact channels only, never a
wildcard and never a direct message; only together with read:memory; only
while the user is acting as that role; and gone on their very next request once
the channel is removed from the role. It reads history — and, when the role also
holds write:activity:message, posts a new message into the channel, by its id;
it never edits or deletes a message there, and never reads the channel’s
channel_message rows. Pilot is unchanged. write:activity:message is
deliberately not write:activity: the narrow capability posts only into
channels the role is granted and does nothing else, where write:activity is
every write on the shared activity table — other connectors’ tasks and posts,
edits of existing rows, and a post into every public channel.
The client-side hasCapability() helper is on the same footing as the manifest.
It reads the session’s own claims to decide what to render, so it is exactly as
trustworthy as the browser it runs in. Use it to explain a denial — a screen that says “ask
your admin for the Auditor role” beats an empty table — never to protect data.
Row-level security is what actually refuses the request, and it refuses it
whether or not the app bothered to ask.
This is why secret-bearing columns are revoked at the grant level rather than
merely gated by an admin RLS policy: a policy that says “admins only” is
satisfied by any script running in an admin’s browser tab. Columns such as
source_install.session_private_key and channel_config.bot_token are therefore
unreachable through /rest/v1/* by any session role, admin included.
Narrowing this — an audience-scoped token with a manifest-derived table allowlist — is a deliberate future change, not a shipped guarantee. Until then, the rule above is the contract.
The one file you copy — lib/turbobase/client.ts
Section titled “The one file you copy — lib/turbobase/client.ts”This is the entire auth layer. There are no route handlers to paste — a same-origin SPA has no server of its own.
import { createClient } from '@supabase/supabase-js';
// Same-origin in production: the instance's API + PostgREST + Realtime are all on// this origin. VITE_SUPABASE_URL is only for local dev (see "Local dev" below).const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || window.location.origin;
function readCookie(name: string): string | null { if (typeof document === 'undefined') return null; const match = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]+)')); return match ? decodeURIComponent(match[1]) : null;}
/** supabase-js client for reads + Realtime, authed by the instance session. */export function browserSupabase() { // Prod: the readable tb_jwt cookie. Dev: an optional pasted token fallback. const jwt = readCookie('tb_jwt') ?? import.meta.env.VITE_TB_JWT ?? ''; const client = createClient(SUPABASE_URL, jwt, { db: { schema: 'turbobase' }, global: { headers: { Authorization: `Bearer ${jwt}`, 'Accept-Profile': 'turbobase', 'Content-Profile': 'turbobase', }, }, auth: { persistSession: false, autoRefreshToken: false }, }); if (jwt) client.realtime.setAuth(jwt); return client;}
/** * Writes go through the canonical write boundary, same-origin. The HttpOnly * turbobase_session cookie rides automatically with `credentials:'same-origin'` * — no Bearer token, no proxy. Reads stay on PostgREST (above); only mutations * go through the boundary. */export async function writeRecord(recordType: string, body: unknown): Promise<Response> { return fetch(`/v1/data/${recordType}`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), });}That’s it — no server-side route handlers and no route guard: a same-origin SPA has none of the auth scaffolding a cross-origin app needs. The serving route handles the not-signed-in case for you.
Why the JWT goes in the apikey position
Section titled “Why the JWT goes in the apikey position”createClient(SUPABASE_URL, jwt, …) — the second argument is normally the public anon key. We pass the session JWT instead. Why:
- The
Authorization: Bearer <jwt>header is the real auth signal. RLS evaluates the JWT in that header — never theapikeyargument. Passing the JWT in both keeps the two consistent. - The session JWT is signed by your instance, so Realtime’s JWKS trusts it. (In local dev, the bundled anon key is signed with a
kidthe instance’s Realtime no longer trusts, which triggers WebSocket 403s — another reason to use the session JWT.)
If you have a reason to pass the anon key instead, get it from /admin/settings and set VITE_TENANT_ANON_KEY, then swap the first createClient argument — the Authorization header still does the work.
Local dev — a Vite proxy makes localhost same-origin
Section titled “Local dev — a Vite proxy makes localhost same-origin”While you build the app, it runs on its own dev server (http://localhost:3000), which is a different origin from the instance — so the same-origin cookie trick doesn’t apply directly. The fix is a Vite dev proxy: forward the instance paths through your dev server so the browser sees one origin.
import { defineConfig } from 'vite';import react from '@vitejs/plugin-react';
const TENANT = process.env.SUPABASE_URL || 'http://localhost:54331';const API = process.env.TURBOBASE_API_URL || 'http://localhost:4100';
export default defineConfig({ plugins: [react()], server: { port: 3000, proxy: { '/rest': { target: TENANT, changeOrigin: true }, '/realtime': { target: TENANT, changeOrigin: true, ws: true }, '/v1': { target: API, changeOrigin: true }, '/auth': { target: API, changeOrigin: true }, '/login': { target: API, changeOrigin: true }, }, },});Now open http://localhost:3000: an unauthenticated load proxies to the instance’s /login, you sign in, the cookies land on localhost:3000, and reads / writes / Realtime all work same-origin — exactly as they will in production. For a quick data smoke without signing in through the proxy, paste a tb_jwt from a signed-in browser session on the instance into VITE_TB_JWT (dev only) and the client helper above uses it as a fallback.
Env vars
Section titled “Env vars”| Var | Where | Value |
|---|---|---|
SUPABASE_URL |
dev only (vite.config.ts) |
Instance PostgREST/Realtime origin for the dev proxy. Prod: unused — the app uses window.location.origin. |
TURBOBASE_API_URL |
dev only (vite.config.ts) |
Instance API origin (/v1, /auth) for the dev proxy. Prod: unused (same-origin). |
VITE_TB_JWT |
dev only, optional | A pasted session JWT the client falls back to when the tb_jwt cookie is absent (local iteration without signing in through the proxy). Never set in production. |
VITE_TENANT_ANON_KEY |
optional | Public anon key from /admin/settings, if you prefer it over the session JWT in the apikey slot. |
There are no server-only env vars — a static SPA has no server. A secret an app would have needed (e.g. an AI key for receipt vision) is not held by the app at all; it calls a first-party instance endpoint instead (see the expenses template’s POST /v1/apps/receipt-extract).
Realtime
Section titled “Realtime”Reads paint first, then Realtime streams deltas into the same state. browserSupabase() already calls client.realtime.setAuth(jwt), so channels carry the user’s role into RLS:
const sb = browserSupabase();sb.channel('dash:invoice') .on('postgres_changes', { event: '*', schema: 'turbobase', table: 'invoice' }, (payload) => { // INSERT → prepend; UPDATE → merge by external_id / pg_row_id. // No DELETE handler: deletes are soft tombstones hidden by RLS, so no frame // is emitted at all — removals come from a reconciliation sweep instead. }) .subscribe();Deletes are the one thing this stream does not carry — see Deletes never arrive for the mechanism and the sweep that replaces them.
Refresh, expiry, and logout
Section titled “Refresh, expiry, and logout”-
Expiry. The session JWT follows the instance’s own session length — 1 day by default, and an administrator may set it per instance or per role — sliding, so every authenticated request resets it. Your app does not refresh manually. If a read or write returns 401 (the session lapsed mid-visit), send the user back to the instance’s sign-in page:
window.location.href = '/login'. The serving route takes it from there. -
A session that ENDED answers 403, not 401, and it is not the same thing. A session can stop being valid long before its token expires: the person signed out in another tab, an administrator suspended them, or their role was changed. Turbobase refuses those at the database, so a read or write of your instance’s tables that used to work comes back 403 — through
/rest/v1, throughPOST /v1/dataand through the live data stream alike. Handle it the same way you handle a 401, and treat the pair as one case:if (res.status === 401 || res.status === 403) {// The session lapsed, ended or changed. Nothing to retry — re-authenticate.window.location.href = '/login';return;}Two things follow for your app. Do not retry a 403 — it is a statement about the session, not a transient failure, and retrying it loops. Do not treat it as “no data” — the refusal is deliberately an error rather than an empty list, precisely so you can tell the two apart.
-
The 403 says which kind it is, in
details. A 403 from/rest/v1carries a JSON body, not an empty one, anddetailsis the field to read:"turbobase:session_dead"means the session itself has ended and signing in again fixes it. Any other 403 means this session may not touch that table or those rows, which signing in again will not change — so an app that tells the two apart can send one user to/loginand show the other a permissions message instead of bouncing them in a loop.{"code": "42501","details": "turbobase:session_dead","message": "this sign-in session has ended","hint": "Sign in again to continue."}messagesays which of the three causes it was — ended, suspended, or access changed — and is written to be shown to a person. Readdetailsto branch, nevermessage. -
A dead session can still answer 200, so never read a 200 as proof it is live. The clearest case is a read with nothing to return: the check runs once per statement and only when the statement actually reaches rows, so a query over a table that is empty for you can come back
200 []on a session that has ended. That one is not a way in — there is nothing there to read. Treat it as the general rule rather than the only exception: handle the 403 wherever it appears, and do not infer that a session is still good from a request that succeeded. -
On
POST /v1/batchthe 403 is on the SUB-RESULT, not on the response. The batch is partial-success by design: the envelope answers200and each sub-query carries its own{ ok, status, body }, so a session that ends between one read and the next comes back as200withresults[i].ok === falseandresults[i].status === 403— the same body, with the samedetails, one level down. Check each sub-result’s own status before you read itsbodyas rows; an app that branches on the envelope alone will hand an error object to a screen as data, which is the “no data” mistake above wearing a 200.const { results } = await batch([...]);const dead = results.find((r) => r.status === 403 && r.body?.details === 'turbobase:session_dead');if (dead) {window.location.href = '/login';return;} -
A live subscription stops delivering changes too, and silence is not a signal. Realtime reads under the same session, so changes stop arriving once it ends. Whether your client is told, or simply goes quiet, is not something to build on — so a long-lived screen should re-read its data on reconnect rather than assume no events means nothing changed.
-
A changed role recovers in one request to the instance — which is why the redirect above is the right move. When an administrator edits the role a signed-in person holds, the instance re-mints their session cookie the next time the browser asks the instance for something: a page load, or a write through
POST /v1/data. A/rest/v1read goes straight to PostgREST and refreshes nothing, so an app that only reads can sit on 403 indefinitely. Sending the user to/loginis what breaks that: the navigation reaches the instance, the cookie is replaced with one carrying their new access, and somebody whose session is otherwise fine is not asked to sign in again. -
Sign-out. Signing out is the instance’s concern — the app is embedded in it, and there is no app-owned session to clear.
/auth/logouttakes a POST, not a link: it clearsturbobase_sessionand thetb_jwttwin and answers{ ok: true, redirect: '/login' }, so send the browser where it says.async function signOut() {const res = await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' });const { redirect } = await res.json();window.location.href = redirect ?? '/login';}
What this page deliberately does NOT cover
Section titled “What this page deliberately does NOT cover”- Sign-up. Turbobase instances don’t self-serve user sign-ups — an admin invites a user (email + role) via
/admin/users, and the row links to the person’s identity on their first sign-in (matched by verified email). - Password reset. Credentials live in the instance’s identity provider, which owns reset and email verification.
- Hosting / deploy. Build your SPA to
dist/, then deploy it from Development › App Management → Deploy a version (orturbobase apps deploy <slug> ./dist). SeeBuild apps on Turbobase.