Skip to content
Engineering

How BYOB Connects Your Supabase Database, A Technical Deep Dive

BYOB Team

BYOB Team

Updated:
14 min read

BYOB connects Supabase through an OAuth 2.0 authorization code flow with PKCE, encrypts tokens with AES 256 GCM, and refreshes them lazily near expiry. The AI runs migrations through a backend tool that injects credentials into the runner environment, so the model only ever sees command output, never secrets.

Key takeaways

  • • OAuth authorization code flow with PKCE and validated state, following RFC 6749 roles and Supabase OAuth app conventions
  • • Tokens encrypted with AES 256 GCM under fresh nonces, refreshed lazily near expiry, with Management API calls inside documented rate limits
  • • The AI runs database commands through a backend injected tool and never sees credentials
  • • The agent workflow runs pull, migration, push, then type generation, with Row Level Security policies that wrap auth calls for performance
How BYOB Connects Your Supabase Database, A Technical Deep Dive

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:

flowchart TD A["User clicks Connect Supabase"] --> B["OAuth redirect to Supabase"] B --> C["Exchange code for tokens"] C --> D["AES-GCM encrypt tokens"] D --> E["Store in supabase_integrations"] E --> F["User: Create a users table"] F --> G["AI calls execute_supabase_cli"] G --> H["Backend decrypts token"] H --> I["Inject into shell environment"] I --> J["supabase db push runs"] J --> K["Output returned to AI"] K --> L["User: Table created!"]

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.


TIP

Try it: JSON Formatter — so payloads stay valid before they reach tables.

Try it right here: json formatter validatorOpen full tool

Loading the interactive tool… or open it here.

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

typescript
// 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}`);
flowchart LR A["Generate 32-byte state"] --> B["Store in _oauth_states"] B --> C["Redirect to Supabase OAuth"] C --> D{"Callback received"} D --> E{"State matches?"} E -->|No| F["Reject: CSRF detected"] E -->|Yes| G["Exchange code for tokens"] G --> H["Fetch user projects"] H --> I["User selects project"] I --> J["Encrypt and store"]

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.

python
@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.

flowchart LR A[Integration accessed] --> B{Within 5 min of expiry?} B -->|No| C[Use existing token] B -->|Yes| D[Call Supabase refresh endpoint] D --> E[Receive new token pair] E --> F[Encrypt and write to DB] F --> C C --> G[Return decrypted token]

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:

json
{
  "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:

flowchart TD A["AI calls execute_supabase_cli"] --> B["Air identifies project_id from session"] B --> C["Fetch encrypted token from DB"] C --> D["Decrypt token in memory"] D --> E["Pass token as env var to MCP runner"] E --> F["Runner executes Supabase CLI"] F --> G["stdout returned to Air"] G --> H["Air sends output to AI model"] H --> I["AI sees output only - never the token"]

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:

flowchart LR A["Container starts"] --> B["Call env-vars/restore-d1"] B --> C["Fetch encrypted secrets from D1"] C --> D["Decrypt with per-project key"] D --> E["Write to .env file"] E --> F["SvelteKit dev server starts"] F --> G["$env/static/public works instantly"]

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:

python
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:

typescript
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:

flowchart TD A["User: Add a user profiles table"] --> B["db pull: fetch current schema"] B --> C["migration new: create file"] C --> D["write_file: SQL into migration"] D --> E["db push: apply to remote"] E --> F{"Push successful?"} F -->|Yes| G["gen types typescript --linked"] G --> H["TypeScript types updated"] F -->|No| I["Read error output"] I --> J["Fix SQL in migration file"] J --> E

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:

sql
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 #

flowchart LR A["Request file URL"] --> B{"In signed_url_cache?"} B -->|Yes| C["Return cached URL"] B -->|No| D["Call create_signed_url with 24h TTL"] D --> E["Cache by storage_path"] E --> C

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:

flowchart TD subgraph Write["Write Path"] W1["User sets env var"] --> W2["Write to container .env"] W2 --> W3["Encrypt with project Fernet key"] W3 --> W4["Store in Cloudflare D1"] end subgraph Read["Read Path on Boot"] R1["Container restarts"] --> R2["Fetch from D1"] R2 --> R3["Decrypt with project key"] R3 --> R4["Write to .env"] R4 --> R5["SvelteKit boots with credentials"] end

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.

Connect your Supabase project

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.

How we picked these

Compared OAuth, Management API, and RLS claims with Supabase docs and OAuth RFC 6749 and reviewed the listed source links.

Frequently asked questions

How does the AI run database commands without seeing secrets?

It calls a backend command runner, the backend decrypts the token into the runner environment, and the model sees only command output. The token lives in process memory, never in model context

How are expiring tokens refreshed?

Expiry is checked on access and refreshed near expiry through the OAuth token endpoint, then encrypted and written back. The first request after expiry pays one roundtrip, and no background scheduler is needed

What do the Management API rate limits allow?

Standard calls run at 120 requests per minute per user per project or organization scope, with stricter limits on expensive endpoints like analytics logs. Integration traffic stays far below these

How should RLS policies stay fast?

Wrap stable auth calls like auth.uid() in a subquery so Postgres evaluates once per statement, index every column policies filter on, and combine grants with policies instead of relying on policies alone

How does the app get credentials at boot?

Encrypted secrets restore to environment files before boot, usable as public URL and anon key variables with zero manual configuration

Changelog

  • • Added flow table, fit guide, and integration notes
  • • Freshness audit September 2026: pointed Row Level Security links at canonical docs path, rechecked Management API limits and all sources at 200 with claims still supported

About the Author

BYOB Team

BYOB Team

The creative minds behind BYOB. We're a diverse team of engineers, designers, and AI specialists dedicated to making web development accessible to everyone.

Ready to start building?

Join thousands of developers using BYOB to ship faster with AI-powered development.

Get Started Free