Skip to content

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’s turbobase_session (HttpOnly) cookie. If the visitor is not signed in, the serving route 302s them to /login on 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 /login route, 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 the turbobase_session cookie 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-origin

The 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.

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 the apikey argument. 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 kid the 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.

vite.config.ts
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.

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).

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.

  • 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, through POST /v1/data and 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/v1 carries a JSON body, not an empty one, and details is 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 /login and 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."
    }

    message says which of the three causes it was — ended, suspended, or access changed — and is written to be shown to a person. Read details to branch, never message.

  • 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/batch the 403 is on the SUB-RESULT, not on the response. The batch is partial-success by design: the envelope answers 200 and each sub-query carries its own { ok, status, body }, so a session that ends between one read and the next comes back as 200 with results[i].ok === false and results[i].status === 403 — the same body, with the same details, one level down. Check each sub-result’s own status before you read its body as 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/v1 read goes straight to PostgREST and refreshes nothing, so an app that only reads can sit on 403 indefinitely. Sending the user to /login is 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/logout takes a POST, not a link: it clears turbobase_session and the tb_jwt twin 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 (or turbobase apps deploy <slug> ./dist). See Build apps on Turbobase.