When you click "Connect Supabase" inside BYOB, something deceptively complex happens in the background. Within seconds, your AI agent can run database migrations, generate TypeScript types, push schema changes, and wire up your SvelteKit frontend. All without you configuring a single environment variable by hand.
Underneath that calm surface sits a valet key system. You hand the attendant a key that starts one car and opens nothing else. The attendant never sees your house keys, never photocopies anything, and returns the car with the tank full. This post is the full engineering story: the OAuth handshake, encrypted storage, the pattern that keeps secrets out of the model's sight, the runtime lifecycle, and the agent workflow that turns plain language into live tables.
The 30-second overview #
Before diving into details, here is the complete flow from clicking "Connect Supabase" to an AI-driven migration running against your live database:
The design principle threading through every component: the AI model never sees your credentials. It invokes a tool called execute_supabase_cli, the backend injects your tokens into the shell environment, and the model only sees command output. Never the token itself.
Try it: JSON Formatter — so payloads stay valid before they reach tables.
How does the OAuth handshake connect your project? #
The integration starts with the authorization code flow from RFC 6749, where the client directs the resource owner to the authorization server and receives an authorization code in return, as stated in OAuth 2.0 RFC 6749 (https://datatracker.ietf.org/doc/html/rfc6749). Supabase's own integration guide builds on exactly this shape: redirect to the authorize URL with client ID, redirect URI, response type, and state, then exchange the returned code at the token endpoint, as stated in the Supabase OAuth integration guide (https://supabase.com/docs/guides/integrations/build-a-supabase-oauth-integration).
Scope and CSRF protection #
BYOB requests broad management scope from Supabase's OAuth server, granting permission to manage database schemas, run CLI operations, and access project settings. Broad scope is a deliberate choice with a matching control: before redirecting, the backend generates a 32-byte cryptographically random state parameter stored server-side. When the callback arrives, the state is validated. Mismatch means rejection before any token exchange, which stops CSRF attacks at the authentication boundary.
Supabase strongly recommends PKCE on top of this, with a SHA256-hashed code verifier sent at authorize time and the raw verifier presented at token exchange. BYOB follows that recommendation, so even a leaked authorization code is useless without the verifier that never left the server session. The shape follows Supabase's documented flow, where the authorize URL carries the client ID, redirect URI, response type, state, and code challenge, and the token endpoint receives the code plus verifier over an authenticated call, as stated in the Supabase OAuth integration guide (https://supabase.com/docs/guides/integrations/build-a-supabase-oauth-integration).
// Authorize URL construction (simplified)
const params = new URLSearchParams({
client_id: OAUTH_CLIENT_ID,
redirect_uri: CALLBACK_URL,
response_type: 'code',
state: randomState, // 32 bytes, stored server-side
code_challenge: sha256(codeVerifier), // S256
code_challenge_method: 'S256',
});
redirect(`https://api.supabase.com/v1/oauth/authorize?${params}`);Project discovery #
Most OAuth integrations get tokens and stop. BYOB takes one extra step: after the code exchange, it calls the Supabase Management API to list all projects owned by the authenticated user. The Management API accepts OAuth tokens in the Authorization header and rate limits standard calls at 120 requests per minute per user per scope, as stated in the Supabase Management API reference (https://supabase.com/docs/reference/api/introduction). Project listing costs one cheap call against that budget.
This lets the user pick exactly which Supabase project gets linked to their BYOB workspace. A developer might own production, staging, and several side projects. They should be explicit about which one the AI agent can touch. Convenience that skips this step is how staging data ends up in demos.
Part 2: credential encryption #
Once the user selects their project, BYOB holds access tokens that need safe storage. All tokens are encrypted using AES-256-GCM before being written to the database.
The wire format is base64(nonce + ciphertext). A fresh 12-byte nonce comes from os.urandom(12) for every encryption, so identical secrets produce different stored blobs. GCM adds authentication on top of secrecy, which means tampered ciphertext fails decryption instead of decrypting into garbage that gets used.
@staticmethod
def _encrypt_env_content(plaintext: str, hex_key: str) -> str:
key = bytes.fromhex(hex_key)
aesgcm = AESGCM(key)
nonce = os.urandom(12) # Fresh nonce every time
ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
return base64.b64encode(nonce + ciphertext).decode("utf-8")Decryption splits the base64 blob at byte 12 to recover the nonce, then decrypts with the same key. The same scheme covers .env files in the project runner, so credentials at rest stay encrypted end to end.
Lazy token refresh #
OAuth access tokens expire. Rather than running a background scheduler to refresh tokens proactively, BYOB uses lazy refresh: expiry gets checked every time an integration is accessed.
The tradeoff is honest. The first request after expiry pays one extra network roundtrip. In exchange there is no scheduler to operate, no refresh worker to monitor, and no race where a scheduled refresh collides with a request already in flight. Revoked access surfaces naturally too: the refresh call returns unauthorized, and the integration reports disconnected instead of failing mysteriously later.
Part 3: the zero-knowledge pattern #
This is the most architecturally interesting piece. How does the AI agent run supabase db push against your database without the model ever seeing your access token?
The tool abstraction #
The AI model gets a tool called execute_supabase_cli. From the model's perspective, it accepts one parameter: command. The system prompt instructs the model that authentication is injected automatically and credentials must never be requested from the user.
When the model calls this tool:
{
"name": "execute_supabase_cli",
"arguments": { "command": "db push" }
}It expects back only command output. Table names, migration status, success or error messages. Nothing else.
The injection layer #
What actually happens when that tool call arrives at the backend:
The model receives migration confirmations and error messages. It never receives the token. Even a prompt injection attack trying to coax the model into printing environment variables finds nothing to print. The token lives in the runner's process environment, not in any model-accessible context.
Why this matters #
Consider a naive implementation that drops the token into the model's context and lets it invoke the CLI directly. It works, and it leaks in at least four ways. The token appears in the context window. It lands in saved chat history. A status line might echo Running SUPABASE_ACCESS_TOKEN=eyJhb... supabase db push into logs. And a clever injection could extract it on demand.
The injection pattern removes all four by construction. Credentials never enter model context, so there is nothing to log, persist, or steal. The valet key stays with the valet stand.
Part 4: the runtime environment lifecycle #
When a BYOB project boots, it needs Supabase credentials available as environment variables before any user code runs. Here is the bootstrap sequence:
The restore function fetches stored secrets from Cloudflare D1, decrypts them with a per-project Fernet key, and writes them to the project's .env file before the SvelteKit dev server initializes.
Per-project key isolation #
Each project gets its own Fernet encryption key stored encrypted in the main database. If one project's key were somehow compromised, only that project's secrets are exposed. There is no global key protecting everything, so there is no single key worth stealing.
The D1 layer encrypts individual secret values with the project key before storage:
encrypted_value = project_fernet.encrypt(value.encode()).decode()SvelteKit environment compatibility #
The provisioned variables follow SvelteKit's PUBLIC_ prefix convention. When PUBLIC_SUPABASE_URL and PUBLIC_SUPABASE_ANON_KEY land in .env, they work immediately from $env/static/public with zero manual configuration:
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);Connect Supabase once. The app has working credentials. No .env editing required.
Part 5: the AI agent's database workflow #
With infrastructure in place, here is the end-to-end flow when a user asks the AI to make schema changes.
The migration workflow #
The agent follows a strict sequence for schema changes that mirrors professional database development:
The db pull step is load-bearing. Without pulling the existing schema first, the model might generate migrations that conflict with existing tables or miss foreign key relationships. Pulling gives the model ground truth about what is actually in the database before writing anything new.
RLS policy generation #
When creating tables holding user data, the agent generates Row Level Security policies:
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own profile" ON user_profiles FOR SELECT
USING ((SELECT auth.uid()) = user_id);
CREATE POLICY "Users can insert their profile" ON user_profiles FOR INSERT
WITH CHECK ((SELECT auth.uid()) = user_id);Two details in that SQL are deliberate. The (SELECT auth.uid()) wrapper lets Postgres evaluate the auth call once per statement instead of once per row, which matters on large tables. And policies alone are not the whole story: Supabase's docs insist grants and policies work as a pair, with per-operation policies plus a test file per table run through the database test suite, as stated in the Supabase RLS guide (https://supabase.com/docs/guides/database/postgres/row-level-security). The agent follows that structure, writing separate SELECT, INSERT, UPDATE, and DELETE policies.
Performance discipline continues after correctness. Index every column policies filter on, since Postgres checks the policy against each candidate row. A policy filtering user_id without an index turns reads into sequential scans as the table grows. The underlying Postgres behavior is standard row security: policies attach Boolean expressions to commands and roles, evaluated per row before user query conditions, as stated in the Postgres row security docs (https://www.postgresql.org/docs/current/ddl-rowsecurity.html).
Part 6: signed URL service #
BYOB stores user-uploaded files in private Supabase Storage buckets. The FreshUrl service handles short-lived access URLs.
The caching strategy #
URLs cache in memory by storage path, so repeated requests for the same asset within a session skip the API call entirely. The security model runs defense in depth: even an intercepted signed URL expires in 24 hours, and files in private buckets stay unreachable without a valid signature. Supabase Storage itself is S3-compatible with fine-grained access control, so bucket policy and signed URLs reinforce each other rather than duplicating, as stated in the Supabase Storage docs (https://supabase.com/docs/guides/storage).
Part 7: the D1 sync layer #
BYOB uses Cloudflare D1 as secondary storage for environment variables that must survive container restarts. Project containers are ephemeral. A restart resets filesystem state, which wipes .env files written during the previous lifecycle.
The D1 sync solves this with a write-through pattern:
Sync runs automatically after any .env modification, so D1 always reflects the latest state. The sync deliberately excludes platform-internal keys, which get re-injected on each boot instead of living in user-managed storage.
The table below traces each step, what it does, and what the model is allowed to see.
| Step | What happens | What the agent sees |
|---|---|---|
| OAuth handshake | You approve access with validated state | Success only, never tokens |
| Token encryption | Secrets rest encrypted until use | Nothing to leak |
| Lazy refresh | Expired tokens renew near expiry | A short retry, never secret text |
| Command runner | Backend injects credentials at run time | Command output only |
| Migration flow | Pull, migrate, push, then generate types | Diffs and results |
| Type generation | Code types mirror the live schema | Ready to use models |
Failure modes and recovery #
Every integration breaks eventually. The interesting question is how it breaks and who notices first.
Revoked access is the common one. A user removes BYOB's authorization in their Supabase dashboard, and the next refresh call fails. The integration marks itself disconnected and tells the user to reconnect. No retries hammering a dead token, no confusing database errors surfacing in the AI chat. The failure lands where the fix lives.
Expired refresh tokens are the slow version of the same story. Supabase rotates token pairs, and a long-idle project can come back to a refresh token that no longer works. Lazy refresh handles this the same way: the access attempt fails, the state flips to disconnected, and reconnecting takes one click through the same OAuth flow. The alternative, a background scheduler refreshing tokens for thousands of idle projects, burns API calls and attention on work nobody needs.
Wrong-project selection is the human failure mode. Someone links production instead of staging and asks the agent to drop a table. Project discovery mitigates this by forcing an explicit choice, and every destructive command runs through the same confirmation habits as the rest of the agent. But tooling cannot fully replace attention. Name your Supabase projects clearly, keep production and staging in separate organizations where possible, and treat the link step as a security decision, because it is one.
Rate limits are the quiet one. At 120 requests per minute per user per scope, normal integration traffic never gets close, as stated in the Supabase Management API reference (https://supabase.com/docs/reference/api/introduction). If you ever hit 429s, something is looping. The fix is backoff plus idempotency on your side, not a bigger quota.
What can the agent never touch? #
Boundaries matter as much as capabilities. The agent operates with the connected user's OAuth permissions, which cover schema management and project configuration. It does not get the database password, so it cannot construct a raw Postgres superuser connection. It does not get the service_role key, which bypasses Row Level Security entirely and must stay server-side.
That last point deserves emphasis because it shapes the whole security story. Supabase maps browser traffic to two Postgres roles: anonymous visitors and authenticated users, with policies deciding which rows each role sees. The service role skips all of it. If that key ever entered model context, every policy in the database would become advisory. Keeping it out is not one control among many. It is the control that makes the others meaningful.
The anon key, by contrast, is safe to expose. It only confers what grants and policies allow, which for a locked-down table is nothing until policies say otherwise. That is why the boot sequence can write PUBLIC_SUPABASE_ANON_KEY into the client environment without worry. The key is public. The data is not, because the database enforces the difference on every query.
What does this integration enable for your app? #
The architecture enables something that takes serious manual work otherwise. A developer describes a data model in plain language, and within minutes has a live Postgres table with correct types and constraints, Row Level Security policies protecting user data, matching TypeScript types ready in SvelteKit, environment variables configured in the running app, and an initialized Supabase client usable from any component.
The engineering stays hidden by design. The injection pattern makes the AI a capable operator of database infrastructure without becoming a security liability. Encrypted storage with per-project keys keeps any single compromise contained. Lazy refresh keeps tokens valid with no background machinery.
The goal was an integration that feels obvious. Making things feel obvious is usually the hardest engineering problem there is.
What we learned building this #
The connection step runs in guided UI and tokens rest encrypted outside model context. Backend session and data access code runs server-side while the agent only sees command output, and who-sees-what stays filtered by owner and role through row-level rules. That split is why the zero knowledge claim holds in practice.
Who this is for (and who should skip it) #
This guide helps builders who outgrow built in storage and need a real Postgres backend with auth. If you want the agent to migrate schema and generate types without ever holding secrets, the flow above is your setup.
Skip it if your app stores little more than content and form leads. Built in database options cover you with less machinery, and Supabase waits until you truly need it.
- Best for developers outgrowing built in storage for real Postgres needs.
- Best for startups adding auth and structured data with typed queries.
- Best for small teams letting the agent migrate schema without sharing secrets.