Engineering

How BYOB Connects Your Supabase Database — A Technical Deep Dive

BYOB Team

BYOB Team

2025-03-19
14 min read
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.

This post is the full engineering story behind that experience. We'll cover the OAuth handshake, how credentials are encrypted and stored, the zero-knowledge pattern that keeps your secrets away from the AI model, the runtime environment lifecycle, and the signed URL service that handles private file access.


The 30-Second Overview

Before diving into details, here's 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 key 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 SUPABASE_ACCESS_TOKEN itself.


Part 1: The OAuth Handshake

The integration starts with a standard OAuth 2.0 authorization code flow, but with a few specific design choices worth calling out.

Scope and CSRF Protection

BYOB requests the all scope from Supabase's OAuth server, granting permission to manage database schemas, run CLI operations, and access project settings.

Before redirecting the user, the backend generates a 32-byte cryptographically random state parameter stored in a temporary dictionary. When the OAuth callback arrives, the state is validated. If it doesn't match, the request is rejected before any token exchange happens — this prevents CSRF attacks at the authentication boundary.

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.

This lets the user pick exactly which Supabase project gets linked to their BYOB workspace. A developer might have a production database, a staging database, and several side projects. They should be explicit about which one the AI agent can touch.


Part 2: Credential Encryption

Once the user selects their project, BYOB has access tokens that need to be stored safely. 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 is generated for every encryption using os.urandom(12), so every stored token has a unique nonce even if the underlying secret is identical.

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 encryption scheme covers .env files in the project runner, so credentials at rest are always encrypted end-to-end.

Lazy Token Refresh

OAuth access tokens expire. Rather than running a background scheduler to refresh tokens proactively, BYOB uses a lazy refresh strategy: expiry is 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: the first request after expiry is slightly slower due to a network roundtrip. But it eliminates background scheduler complexity and the race condition where a scheduled refresh runs while a request is already in flight.


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 SUPABASE_ACCESS_TOKEN?

The Tool Abstraction

The AI model has access to a tool called execute_supabase_cli. From the model's perspective, it accepts a single parameter: command. The system prompt explicitly instructs the model: "Authentication and access_token are ALL injected automatically. You MUST NEVER ask the user for credentials."

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.

The Injection Layer

What actually happens when that tool call arrives at the Air 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 if a prompt injection attack tried to get the model to print its environment variables, there would be 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 gives the AI model direct token access and lets it invoke the CLI itself. This works, but means:

  • Your SUPABASE_ACCESS_TOKEN appears in the model's context window
  • It gets saved to chat history
  • A status message might read: Running SUPABASE_ACCESS_TOKEN=eyJhb... supabase db push
  • A sufficiently clever system prompt injection could extract it
The zero-knowledge pattern prevents all of this by design. Credentials never enter the model's context.


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's 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_env_from_d1 function fetches all stored secrets from Cloudflare D1 (BYOB's edge database), decrypts them using a per-project Fernet key, and writes them to the project's .env file. This runs 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, it would only affect that project's secrets — not a global key protecting everything.

The D1 storage layer encrypts individual secret values with this per-project key before storage:

python
encrypted_value = project_fernet.encrypt(value.encode()).decode()

SvelteKit Environment Compatibility

The provisioned variables use SvelteKit's PUBLIC_ prefix convention. When PUBLIC_SUPABASE_URL and PUBLIC_SUPABASE_ANON_KEY land in .env, they're immediately usable 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. Your SvelteKit app has working credentials. No .env editing required.


Part 5: The AI Agent's Database Workflow

With infrastructure in place, here's 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 critical. 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's actually in the database before writing anything new.

RLS Policy Generation

When creating tables containing user data, the agent automatically 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);

Note the (SELECT auth.uid()) pattern rather than bare auth.uid(). This wraps the auth call in a subquery, causing Postgres to cache the result per query rather than evaluating it per row. For tables with many rows, this is a meaningful performance gain that the agent applies automatically.


Part 6: Signed URL Service

BYOB stores user-uploaded files in private Supabase Storage buckets. The FreshUrl service handles generating 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 are cached in memory by storage path, so repeated requests for the same asset within a session skip the API call entirely. The security model is defense-in-depth: even if a signed URL were intercepted, it expires in 24 hours. The underlying files in private buckets are never accessible without a valid signature.


Part 7: The D1 Sync Layer

BYOB uses Cloudflare D1 as a secondary storage layer for environment variables that need to survive container restarts. Project containers are ephemeral — a restart resets filesystem state, which means .env files written during one container lifecycle disappear in the next.

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_to_d1 is called automatically after any .env modification, so D1 always reflects the latest state. The sync deliberately excludes VITE_EXPORT_API_KEY — that's a platform-internal key re-injected on each boot, not something that belongs in user-managed storage.

What This Enables

The architecture enables something that would take significant manual work to set up. A developer describes a data model in plain language, and within minutes has:

  • A live Postgres table with correct column types and constraints
  • Row Level Security policies protecting user data
  • TypeScript types matching the schema, immediately usable in SvelteKit
  • Environment variables configured and ready in the running app
  • A Supabase client initialized and usable from any component
The engineering that makes this seamless is deliberately hidden from the surface. The zero-knowledge credential pattern means the AI is a capable operator of database infrastructure without being a security liability. Encrypted storage and per-project key isolation mean a compromise of one project doesn't cascade. The lazy refresh strategy keeps tokens valid without background jobs.

The goal was an integration that works so smoothly it feels obvious — and making things feel obvious is usually the hardest engineering problem.

Connect your Supabase project

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