Chapter 1. Managed Application and Data Platforms (Foundations: Vercel & Supabase)

Learning objectives

  • Configure Vercel deployment environments (Preview vs. Production) with strict isolation, and distinguish the exposure boundary between NEXT_PUBLIC_ variables and server-only environment variables.
  • Lock Preview deployments against unauthorized public access using Deployment Protection, and safely design automation bypass secrets (x-vercel-protection-bypass) so automated E2E tests and webhooks can pass through.
  • Place serverless function regions (for example, Seoul icn1) close to your primary database, and explain how execution time limits (maxDuration), Fluid Compute, and response streaming affect AI latency and compute cost.
  • Understand the difference between transaction mode (port 6543) and session mode (port 5432) in Supavisor, the Supabase Postgres connection pooler, and select the right connection setup for each workload.
  • Explain how the USING clause and the WITH CHECK clause differ in PostgreSQL Row Level Security (RLS), and write policies that preserve the trust boundary between the anon key and the service_role key.
  • Design Supabase Auth redirect URL allowlists (covering both web origins and mobile deep links) and access controls for private Storage buckets using time-limited signed URLs.

Introduction

Imagine a startup team that builds an impressive AI document-summarization web application with a coding agent during a weekend hackathon. When they push the code that worked on their laptops to GitHub and connect the repository to Vercel, a globally reachable HTTPS URL goes live in under a minute. Connecting a Supabase project gives them user sign-in, a relational database, and file uploads almost immediately. Thrilled by the speed, the team announces their launch on social media Monday morning.

Within two hours of launch, three crises strike back-to-back. First, a curious user opens the browser developer tools, inspects the Network tab and the downloaded JavaScript bundles, and extracts the team's paid LLM API key and their Supabase administrator key in plaintext. A developer had prefixed every environment variable with NEXT_PUBLIC_ so the frontend framework could read them conveniently. Second, as soon as roughly 200 concurrent users arrive, the summarization screen freezes for over three seconds before failing with database connection exhaustion errors reading remaining connection slots are reserved for non-replication superuser connections. Their serverless functions were running in the default US East region (iad1) while their database sat in Seoul (icn1), stretching every round trip across the Pacific, and each function instance opened a direct connection on port 5432 without releasing it. Third, the Preview URL for an unreleased paid feature branch scheduled for next month was indexed by search engines and shared on an online forum, allowing anyone to open and use the experimental feature without signing in.

Managed platforms such as Vercel and Supabase compress infrastructure setup time from days into minutes, but that convenience never means "the default settings are safe for production." On the contrary, because deployment is so effortless, a single environment variable prefix, a single port number, or a single unchecked RLS box immediately defines your production security perimeter. This chapter examines the nine foundational principles and design standards that elevate a managed application and data platform from a fragile hackathon prototype to a hardened production system.

Key concepts

Vercel environment separation: The boundary between Preview and Production

The very first pillar to establish on a managed application platform is environment separation. When connected to a Git repository, Vercel distinguishes three primary environments: Development, which runs on a developer's local workstation; Preview, which is built automatically for every feature branch push or pull request (PR); and Production, which serves your live customer domain when changes are merged into the production branch (such as main) or explicitly promoted.

By analogy, Production is the main dining room of a restaurant serving paying guests, whereas Preview is the test kitchen where chefs experiment with new recipes and rehearse kitchen workflows. What happens if the test kitchen and the main dining room share the same cash register, the same ingredient vault, and the same order tickets? A chef testing a refund workflow in the test kitchen might accidentally cancel a real guest's payment in the dining room, or thousands of synthetic load-test orders might pollute the restaurant's real accounting ledger.

Strict environment separation therefore follows four ironclad rules.

Dimension Preview environment Production environment What goes wrong without separation
Domain Unique branch- or commit-specific preview URL Live customer domain (for example, app.example.com) Unreviewed or experimental code is exposed directly to real customers
Database and storage Dedicated dev/staging DB or isolated test project Production DB holding real customer records Experimental schema migrations or E2E test runs corrupt or pollute production data
External payment and messaging keys Sandbox (test) keys, outbound delivery disabled Live payment keys and live notification credentials Test runs charge real credit cards or send test alerts to real customers
Auth and developer backdoors Restricted test accounts allowed (behind Deployment Protection only) Test sign-in and privilege-grant APIs completely disabled Attackers use development privilege-grant endpoints to escalate to admin access

In particular, when you register an environment variable in the Vercel dashboard, you choose which target environments (Production, Preview, Development) receive that value via individual checkboxes. If you carelessly leave all three boxes checked when adding your production database connection string (DATABASE_URL) or live payment secret key, any developer who opens a pull request to spin up a Preview deployment can print production secrets to the console logs or connect directly to the production database. Production secrets must be injected exclusively into the Production environment, while Preview receives separate staging credentials. When a specific feature branch needs an isolated configuration override, you can further scope variables using branch-specific environment variables.

Environment variables and the NEXT_PUBLIC_ exposure boundary

Modern web application frameworks such as Next.js keep server-side code and client-side code (which is shipped to and executed inside the user's browser) inside the same repository. The single most frequent security incident experienced by beginners and seasoned engineers alike is leaking secret environment variables into the client JavaScript bundle.

To keep applications secure by default, the build toolchain treats all environment variables as server-only unless told otherwise. Variables such as process.env.OPENAI_API_KEY or process.env.SUPABASE_SERVICE_ROLE_KEY can only be read inside server-side execution contexts—Server Components, Route Handlers, and Server Actions running on Vercel's server infrastructure—and are stripped out of the JavaScript files sent to the browser.

However, the moment an environment variable name starts with NEXT_PUBLIC_ (for example, NEXT_PUBLIC_SITE_URL or NEXT_PUBLIC_SUPABASE_URL), the behavior changes completely. At build time, the bundler looks up the actual string value of that variable and inlines it verbatim into the client JavaScript bundle files.

// Server-only code (Route Handler / Server Action) — SAFE
// This code runs only on the server and is never included in the browser bundle.
const apiKey = process.env.LLM_SECRET_API_KEY;

// Client Component ("use client") — EXPOSED TO THE ENTIRE WORLD!
// At build time, the expression below is replaced with the literal string value
// (e.g., "https://proj.supabase.co"), which anyone can read in browser DevTools.
const publicUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;

Two critical properties follow from this mechanism:

  1. Any value prefixed with NEXT_PUBLIC_ is public information, never a secret. Even if you name a variable NEXT_PUBLIC_SECRET_KEY, its literal value is broadcast to every visitor's browser just as if you had typed it directly into your public HTML source. You must never attach the NEXT_PUBLIC_ prefix to LLM API keys, database connection strings, Supabase service_role keys, webhook signature verification secrets, or encryption keys.
  2. NEXT_PUBLIC_ variables are frozen at build time. Whereas server-only environment variables are read dynamically when a server function handles a request, NEXT_PUBLIC_ values are baked into static JavaScript files during next build and uploaded to the CDN. If you change a NEXT_PUBLIC_ value in the Vercel dashboard, you must trigger a redeploy (a fresh build) for the new value to take effect in users' browsers.

Caution

When developers try to read a server-only variable inside a Client Component and see undefined, they sometimes slap NEXT_PUBLIC_ onto the existing secret variable just to make the error disappear quickly. That is the equivalent of hanging the vault key on the front door bulletin board because a clerk found the vault locked. Whenever a browser action requires a secret key (such as calling an LLM or confirming a payment), you must route the request through a server-side Route Handler or Server Action.

Deployment Protection and designing automation bypasses

Every pull request deployed to Vercel generates a unique Preview URL (such as my-app-git-feat-new-ui-team.vercel.app). Anyone on the internet who knows or guesses that URL can reach the endpoint. If you leave Preview deployments unprotected, unreleased features that have not passed security review, draft internal admin screens, or confidential marketing pages are exposed to the public internet—and search engine crawlers may even index them.

Vercel's Deployment Protection places an authentication gate in front of Preview deployments (as well as default .vercel.app production domains):

  • Vercel Authentication: Restricts access so that only logged-in members of your Vercel team can open Preview URLs.
  • Password Protection: Allows external partners or reviewers to open the preview only if they enter a shared password.
  • Trusted IPs: Restricts access to requests originating from corporate static IP ranges or VPN gateways.

A practical engineering challenge arises when automated tools rather than human users need to reach a protected Preview URL. For example, if a GitHub Actions workflow runs Playwright end-to-end (E2E) tests against a freshly deployed Preview URL, if an external payment provider sends a sandbox webhook to /api/webhook on the preview server, or if a backend CI job runs a health check, the request hits the Vercel Authentication sign-in wall (receiving HTTP 401/403 or an HTML login redirect) and fails.

Instead of turning off Deployment Protection altogether, the standard architectural solution is Protection Bypass for Automation. Once you generate an automation bypass secret (VERCEL_AUTOMATION_BYPASS_SECRET) in the project settings, your automated client includes the following HTTP headers on its requests:

GET /api/health HTTP/1.1
Host: my-app-git-feat-team.vercel.app
x-vercel-protection-bypass: <GENERATED_BYPASS_SECRET_VALUE>
x-vercel-set-bypass-cookie: true
  • When the value of the x-vercel-protection-bypass header matches the project's bypass secret, the Vercel edge skips the authentication screen and forwards the request directly to your application.
  • For browser-based E2E test suites such as Playwright—which load an initial page and then make dozens of subsequent requests for scripts, stylesheets, and client-side route transitions—sending x-vercel-set-bypass-cookie: true (or samesitenone for cross-site iframe/federated testing) instructs Vercel to set a signed bypass cookie in the browser so every follow-up request passes seamlessly.
  • When an external third-party webhook sender cannot attach custom HTTP headers, you can pass the secret as a query parameter (?x-vercel-protection-bypass=<SECRET>) in a tightly scoped manner; however, because URLs can appear in logs, header-based bypass is strongly preferred, and your endpoint must still verify the webhook payload's cryptographic HMAC signature.

Function region placement, execution time limits, Fluid Compute, and streaming

Static assets (images, CSS, and compiled JS bundles) are replicated across global CDN edge nodes so they are served immediately from whichever location is closest to the user. But where should you place Serverless Functions that query your relational database and call external LLM APIs?

Many developers assume "functions should also run as close to the user as possible," or they leave the setting untouched so functions deploy to Vercel's default region in the eastern United States (iad1, Washington, D.C.). If your primary database (Supabase Postgres) lives in Seoul (ap-northeast-2, corresponding to Vercel region code icn1), that mismatch creates a severe latency penalty.

Let's compare the two configurations when a user in Seoul sends a request that executes five sequential database queries.

Configuration Request path and round-trip distance Network latency for 5 sequential DB queries
Function in iad1 (US East) + DB in Seoul Seoul user → US function (180 ms) → 5 round trips to Seoul DB (5 × 180 ms = 900 ms) → Seoul user (180 ms) ~1,260 ms or more (explodes as query count grows)
Function in icn1 (Seoul) + DB in Seoul Seoul user → Seoul function (15 ms) → 5 round trips to Seoul DB (5 × 3 ms = 15 ms) → Seoul user (15 ms) ~45 ms (function and database sit in the same metro region)

When handling a single HTTP request, the network round trip between the user's browser and the serverless function happens only once. By contrast, round trips between the serverless function and the database happen multiple times in a sequential waterfall within that single request—verifying the session, loading the user profile, checking permissions, querying records, and writing an audit row. Therefore, you must always pin your serverless function region to the region closest to your primary database (for example, icn1 for a database in Seoul). A single "regions": ["icn1"] setting in vercel.json immediately shaves hundreds of milliseconds off every dynamic request.

In AI applications, function execution time limits (maxDuration), response streaming, and Fluid Compute are just as critical as regional placement:

  1. Execution time limit (maxDuration): Typical CRUD APIs finish in under a second, but calling a reasoning model or running a multi-step tool-using agent can easily take 30 seconds to several minutes. If you do not explicitly configure export const maxDuration = 60; (or a higher limit supported by your plan) on that route, Vercel forcefully terminates the function mid-generation when the default timeout expires—leaving the user staring at a broken screen while you still pay the LLM provider for the generated tokens.
  2. Streaming (SSE / ReadableStream): Flushing tokens to the browser as soon as the LLM emits the first token dramatically lowers Time to First Byte (TTFB) and perceived latency, while keeping the HTTP connection active so intermediate proxies do not drop the request for idleness.
  3. Fluid Compute: In traditional serverless execution models, each incoming request occupied a dedicated function instance; while that function sat idle waiting 20 seconds for OpenAI or Anthropic to stream tokens back (I/O wait), the instance's CPU and memory remained locked, driving up both cold starts and compute billing. Vercel's Fluid Compute takes advantage of asynchronous I/O in Node.js and Python runtimes so a single warm function instance can handle multiple concurrent requests while waiting on network I/O. This drastically reduces both cold-start frequency and idle compute charges during slow LLM calls. Furthermore, when you need to write audit logs or record token usage metrics after the final response has already been sent to the user, the waitUntil() API lets the function finish background post-processing without delaying the user-facing response.

Supabase Postgres and Supavisor connection pooling: 6543 vs. 5432

PostgreSQL traditionally uses a process-per-connection architecture: every time a client opens a database connection, the database server spawns a separate operating-system process and allocates dedicated memory for it. Because of this overhead, the maximum number of direct concurrent connections (max_connections) a single PostgreSQL instance can sustain is strictly capped—ranging from a few dozen on small instances to a few hundred on larger ones.

In a traditional long-running server architecture (for example, two persistent EC2 instances), each server opens a fixed pool of 10 connections at startup and reuses them across requests (20 connections total), so connection limits are rarely an issue. In a serverless environment like Vercel, however, a sudden traffic spike spins up dozens or hundreds of ephemeral function instances at once. If every function instance attempts to perform a direct TCP + TLS + authentication handshake with PostgreSQL, max_connections is exhausted in seconds.

To solve this problem, Supabase places a cloud-native connection pooler called Supavisor in front of PostgreSQL. Supavisor accepts thousands of lightweight client connections from your application and multiplexes their queries over a much smaller pool of real backend PostgreSQL connections. Supavisor exposes two distinct modes depending on the port number, and distinguishing between them is vital both on the exam and in production.

Comparison dimension Transaction Mode Session Mode Direct Connection
Default port 6543 (Supavisor pooler) 5432 (Supavisor pooler host) 5432 (Direct DB host, IPv6 by default)
Backend connection lease unit Leased only for the duration of a single transaction (BEGIN to COMMIT) and returned to the pool immediately Dedicated 1:1 to the client until the client disconnects Dedicated 1:1 connection directly to a Postgres backend process without a pooler
Ideal workload Serverless functions (Vercel, Edge, Lambda) and high-concurrency web APIs Persistent backend servers, or long-running jobs that require session-level state (SET, temp tables) Schema migrations (CREATE/ALTER), pg_dump, and administrative tools
Session-level feature constraints Because the underlying connection changes between transactions, session-level PREPARE (prepared statements), SET, LISTEN/NOTIFY, and session temp tables do not persist All session-level features work normally (but concurrent connection slots fill up just as fast as direct connections) All PostgreSQL features available without restriction

When using Supavisor from a serverless application, you must follow three operational rules:

  1. Point runtime application traffic to port 6543 (Transaction Mode). Even if hundreds of serverless function instances spin up simultaneously, each instance borrows a real PostgreSQL connection only for the few milliseconds required to execute its query and immediately returns it to the pool, preventing connection exhaustion.
  2. Disable prepared-statement caching in your ORM or database driver, or enable pooler-compatibility mode. In Transaction Mode, the backend connection that receives your first query (PREPARE stmt_1) may be different from the backend connection assigned to your next query, causing prepared statement "s1" does not exist errors. For example, append ?pgbouncer=true to the connection string in Prisma, or set prepare: false in postgres.js.
  3. Separate database migrations (migrate) onto Session Mode or a Direct Connection (port 5432). Most migration tools acquire session-level PostgreSQL advisory locks (pg_advisory_lock) to prevent concurrent migration runs and execute long-running DDL statements. Standard best practice is to maintain two variables: DATABASE_URL on port 6543 for application runtime traffic, and DIRECT_URL on port 5432 exclusively for migrations. Furthermore, if your application needs to set session configuration parameters inside Transaction Mode, never use plain SET (which lingers on the pooled connection and leaks into the next user's request!). Instead, always wrap the query in a transaction and use SET LOCAL (for example, SET LOCAL role, SET LOCAL app.user_id = ...), which is automatically reset the moment the transaction commits.

Row Level Security (RLS): How USING and WITH CHECK actually work

Supabase automatically generates REST and GraphQL APIs (via PostgREST) directly on top of your PostgreSQL schema so browsers and mobile apps can query and mutate data without custom CRUD boilerplate. While this architecture is remarkably productive, it means your database tables themselves are the front-line security perimeter on the public internet. If you create a table in the exposed public schema and forget to enable Row Level Security (RLS) (ALTER TABLE ... ENABLE ROW LEVEL SECURITY), anyone holding your public anon key can read, overwrite, or delete every single row in that table.

The moment you enable RLS on a table, PostgreSQL switches that table to a Default Deny posture: unless an explicit policy permits an operation, zero rows are visible and zero writes succeed (except for the table owner or roles carrying the BYPASSRLS attribute, such as service_role).

When authoring RLS policies, the point that confuses engineers most often is the exact difference between the USING clause and the WITH CHECK clause. Think of USING as "Which existing items on the warehouse shelf am I allowed to see or touch? (filtering existing rows)", and WITH CHECK as "Does the new or modified item I am placing onto the shelf pass inspection? (validating new row state)".

ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;

-- SELECT (Read): Users can only view rows they own.
CREATE POLICY "docs_select_own" ON public.documents
  FOR SELECT TO authenticated
  USING ( (SELECT auth.uid()) = owner_id );

-- INSERT (Create): Users can only insert new rows under their own user ID.
CREATE POLICY "docs_insert_own" ON public.documents
  FOR INSERT TO authenticated
  WITH CHECK ( (SELECT auth.uid()) = owner_id );

-- UPDATE (Modify): Users can only target their own existing rows (USING),
-- and the modified row after the update must still belong to them (WITH CHECK).
CREATE POLICY "docs_update_own" ON public.documents
  FOR UPDATE TO authenticated
  USING ( (SELECT auth.uid()) = owner_id )
  WITH CHECK ( (SELECT auth.uid()) = owner_id );

-- DELETE (Remove): Users can only delete existing rows they own.
CREATE POLICY "docs_delete_own" ON public.documents
  FOR DELETE TO authenticated
  USING ( (SELECT auth.uid()) = owner_id );

The table below summarizes how both clauses behave across each SQL command and what goes wrong when they are misconfigured.

SQL command Role of the USING clause (checks existing rows) Role of the WITH CHECK clause (checks new/updated rows) Common design mistake
SELECT Includes only rows where the condition evaluates to true (silently hides rows that evaluate to false) Not applicable (no new rows are produced) Leaving RLS disabled or writing USING (true) on a table containing private data
INSERT Not applicable (there is no existing row to filter) Rejects the insert with an error if the newly proposed row fails the condition Writing WITH CHECK (true), allowing an attacker to insert rows spoofing someone else's owner_id
UPDATE Filters which existing rows the user is allowed to target for modification Re-validates the new state of the row after the update is applied Specifying USING without restricting mutable columns, or allowing users to reassign ownership to another tenant
DELETE Filters which existing rows the user is allowed to delete Not applicable (no row remains after deletion) Assuming a SELECT policy automatically prevents deletion, or omitting the ownership filter

Note

If you write an UPDATE policy with a USING clause and omit the WITH CHECK clause, PostgreSQL automatically copies the USING expression and applies it as WITH CHECK as well. In real applications, however, the pre-update condition and post-update condition often differ—for example, "a user may only edit their own document while it is in draft status (USING (owner_id = (SELECT auth.uid()) AND status = 'draft')), and after editing, the status may only be draft or submitted, never self-approved (WITH CHECK (owner_id = (SELECT auth.uid()) AND status IN ('draft', 'submitted')))." Writing both clauses explicitly on every UPDATE policy is a much safer habit. In addition, wrapping auth.uid() in a scalar subquery like (SELECT auth.uid()) = owner_id and indexing owner_id allows the PostgreSQL query planner to evaluate the function once per query (InitPlan) instead of once per row, dramatically improving performance on large tables.

Trust boundaries between the anon key and the service_role key

Every Supabase project provides two fundamentally different API keys (alongside the newer Publishable Key and Secret Key format):

  1. anon key (Publishable Key): A public key designed to be embedded in browser bundles and mobile apps. Before a user signs in, requests carrying this key run under PostgreSQL's anon role; once a user signs in through Supabase Auth and receives a signed JWT access token, requests switch to the authenticated role. In both cases, every query is strictly governed by your RLS policies. Exposing the anon key in client code is normal and expected—your security boundary depends not on keeping the anon key secret, but on making sure RLS is enabled and airtight on every table.
  2. service_role key (Secret Key): A superuser-equivalent secret key intended strictly for trusted backend administrative tasks, payment webhook handlers, and background batch jobs. Any request authenticated with the service_role key carries PostgreSQL's BYPASSRLS privilege, which completely ignores every RLS policy and can read, modify, or delete any row in the database.

Treat the service_role key like the root master key to your database:

  • Never embed it in browser code (NEXT_PUBLIC_), mobile application binaries, or public Git repositories.
  • Even inside backend server code, never share a service_role client across normal user-facing request handlers out of convenience. The instant you query with service_role, the RLS safety net disappears; if a developer forgets a single .eq('user_id', currentUserId) filter in application code, the endpoint will happily return another customer's private records (a classic Confused Deputy vulnerability).
  • Server routes acting on behalf of a signed-in user should pass the user's session token so PostgreSQL enforces RLS (or set a transaction-local context), reserving service_role exclusively for isolated system paths—such as processing an asynchronous payment webhook or running a scheduled maintenance cron job—where no user session exists.

Supabase Auth callback allowlists and mobile/web authentication flows

When users sign in with OAuth social providers (Google, Apple, GitHub) or click an email magic link or password-reset link, the authentication server finishes verifying their identity and redirects their browser back to a URL specified by the application (redirectTo), appending an authorization code or session token.

If an attacker can tamper with the login request to set redirectTo=https://evil.example.com/callback and the authentication server blindly honors any destination (an Open Redirect vulnerability), the victim's freshly minted authorization code or access token is delivered straight into the attacker's hands the moment they finish signing in.

To prevent token theft via open redirects, Supabase Auth enforces two configuration settings:

  • Site URL: The default production origin where users are sent when no explicit redirectTo parameter is provided (for example, https://aidan.agentcat.app).
  • Redirect URLs (Allowlist): The explicit list of exact URLs or tightly scoped patterns permitted as redirectTo targets. If a sign-in request asks to redirect to an address not on this allowlist, Supabase Auth rejects the target or falls back to the default Site URL.

Services that run both a web application and native mobile apps must manage this allowlist carefully across environments.

Environment / Platform Allowlist registration example Key security precaution
Production web https://app.example.com/** Always specify https:// rather than http://, and only use subdomain wildcards (https://*.example.com/**) if no untrusted user content is hosted on sibling subdomains
Preview deployments https://*-myteam.vercel.app/** Never allow a blanket https://*.vercel.app/**, which would let an attacker redirect tokens to their own Vercel project; scope the pattern to your team or project slug
Native mobile apps myapp://auth/callback Register the exact custom URL scheme (deep link) or Universal Links / App Links path used by the native shell
Local development http://localhost:3000/** Never accidentally set localhost as the default production Site URL in a shared or live project

Furthermore, when server-side rendering (SSR) and native mobile apps coexist, you should always use the PKCE (Proof Key for Code Exchange) authentication flow. Unlike the legacy Implicit flow—which places #access_token=... directly in the URL fragment where browser extensions, referrer headers, or shared logs might capture it—PKCE returns a one-time authorization code that can only be exchanged for session tokens by presenting the secret code_verifier generated by the original client that initiated the sign-in.

Supabase Storage: Public vs. private buckets and signed URLs

AI applications routinely handle user-uploaded PDFs, voice recordings, identity or certificate scans, and generated media files. Supabase Storage stores the raw file bytes in S3-compatible object storage while controlling who can read, upload, update, or delete objects at any path (bucket_id, name) through PostgreSQL RLS policies on the storage.objects table.

When creating a Storage bucket, your first architectural decision is whether to make it a Public Bucket or a Private Bucket:

  • Public Bucket (public = true): Anyone who knows the file's URL path can download the object directly through the CDN without presenting an authentication header or passing a SELECT RLS check. Public buckets should be used only for assets with zero confidentiality requirements, such as company logos, public course cover images, or intentionally shared achievement badges. Assuming that confidential contracts are safe in a public bucket simply because you named the files with random UUIDs (security by obscurity) is dangerous: once a public URL leaks via browser history, an HTTP Referer header, or a shared log, anyone can download that file forever without authentication.
  • Private Bucket (public = false): Every download and upload request requires authentication and must satisfy the RLS policies defined on storage.objects. Customer-uploaded source documents, personalized score report PDFs, and identity verification photos must always reside in a Private Bucket.

How, then, should you let an authenticated user view their own private file—or allow an upload of a large file without choking your serverless functions? Two standard patterns are used in practice:

  1. User-session RLS with structured path conventions: Store files under predictable tenant or user prefixes such as {user_id}/{document_id}.pdf, and attach an RLS policy on storage.objects checking (storage.foldername(name))[1] = (SELECT auth.uid()::text) so authenticated users can only access files inside their own folder.
  2. Time-limited Signed URLs: After your server verifies the requester's permission, it calls createSignedUrl(path, 60) to mint a cryptographically signed URL with a short expiration window (such as 60 seconds to 5 minutes) and returns it to the client. Once the expiration timestamp passes, the link becomes completely invalid, minimizing the exposure window even if the URL is accidentally shared. Similarly, when a client needs to upload a large file (for example, a 30 MB PDF or audio recording), calling createSignedUploadUrl on the server lets the browser upload the file directly to Supabase Storage—bypassing serverless function request body size limits (such as Vercel's 4.5 MB payload cap) while still enforcing strict server-side authorization.

Going deeper

Serverless cold starts, DB TLS handshakes, and transaction-local context

To appreciate why regional distance and connection pooler modes have such a dramatic effect on serverless database performance, let's trace what happens on the wire at the packet level.

When a fresh serverless function instance wakes up (a cold start) and opens a brand-new connection to a remote PostgreSQL server, it must complete five sequential steps before a single row of application data can be fetched:

  1. Resolve the database hostname to an IP address via DNS.
  2. Complete the TCP 3-way handshake (SYN → SYN-ACK → ACK), consuming 1 network round trip (1 RTT).
  3. Negotiate PostgreSQL's SSLRequest packet and complete the TLS 1.2/1.3 handshake to verify the server certificate and exchange symmetric encryption keys, consuming another 1–2 RTTs.
  4. Send the PostgreSQL startup packet and complete SCRAM-SHA-256 challenge-response authentication, consuming 2 RTTs.
  5. Finally send the first SELECT query and receive the result set (1 RTT).

In total, running just the first query on a fresh connection requires at least 5 to 6 network round trips (RTTs). If your function runs in iad1 (US East) and your database sits in icn1 (Seoul) with a one-way RTT of 180 ms, establishing the connection alone burns roughly 1,000 ms (1 full second) before your 1 ms indexed query even executes! By placing the function in icn1 so 1 RTT drops to 2–3 ms, and letting Supavisor keep warm backend PostgreSQL connections open ahead of time, you compress that entire connection setup cost down to a few milliseconds.

Transport security adds an equally important requirement. Whenever your application or migration script connects to a remote database over the network, it must enforce TLS encryption and strict server certificate verification (rejectUnauthorized: true with the provider's root CA certificate) to prevent man-in-the-middle (MITM) interception. Setting ssl: { rejectUnauthorized: false } in production code just to silence a certificate configuration error tells your driver to happily send queries and passwords over an untrusted connection to any attacker impersonating your database.

Furthermore, applications that manage their own server-side sessions often pass the authenticated user's identity into PostgreSQL so RLS policies or audit triggers can read it via current_setting(...). As we saw earlier, if you run a session-level SET app.current_user_id = 'u_123' over Supavisor Transaction Mode (port 6543), that setting remains attached to the underlying pooled connection after your request finishes—handing your user's privileges to whichever stranger happens to borrow that pooled connection next (session contamination). Behind a transaction pooler, you must always wrap the work in a BEGIN ... COMMIT transaction block and execute SET LOCAL (or set_config('...', '...', true)), guaranteeing that the setting is scoped strictly to the current transaction and wiped clean the instant the transaction ends.

RLS performance traps and bypass patterns in multi-tenant AI services

Row Level Security is a formidable security control, but poorly written policy expressions can drive database CPU utilization to 100% and grind the entire service to a halt once a table grows past 100,000 rows. That happens because PostgreSQL attaches your RLS policy expression as a mandatory security barrier filter on every single row scan.

Let's compare the two most common RLS performance traps and their optimized remedies.

Pattern Slow RLS policy expression Optimized RLS solution Why the performance differs
Repeated per-row function call USING ( auth.uid() = user_id ) USING ( (SELECT auth.uid()) = user_id ) Wrapping the call in a scalar subquery lets the planner evaluate auth.uid() once per query (InitPlan) and use an index scan on user_id instead of calling the function on every row
Unindexed many-to-many membership join USING ( team_id IN (SELECT team_id FROM members WHERE members.user_id = auth.uid()) ) without indexes Create a composite index on members(user_id, team_id) + use a SECURITY DEFINER helper or JWT claim Without an index on the join columns, PostgreSQL performs a full sequential scan of members when evaluating access

When you introduce a SECURITY DEFINER function to break RLS recursion or speed up membership checks, you must handle it with extreme care. By default, PostgreSQL functions run as SECURITY INVOKER, meaning they execute with the permissions and RLS restrictions of the calling user. By contrast, a SECURITY DEFINER function executes with the privileges of the user who created the function (typically a superuser or schema owner), bypassing RLS inside the function body. If you create a SECURITY DEFINER function that accepts arbitrary parameters and returns table rows directly—and leave EXECUTE permission granted to the public or anon role—any unauthenticated user can invoke that function via RPC to bypass RLS completely and dump your database. Therefore, every SECURITY DEFINER function must explicitly lock its schema search path (SET search_path = '' to prevent object-shadowing attacks), return only a strict boolean or minimal set of authorized IDs, and REVOKE EXECUTE from roles that do not need to call it directly.

Workplace scenarios

Hanbit Edu: Environment variable leaks and Supavisor connection exhaustion caught right before launch

Hanbit Edu, an edtech startup, built a web service that provides real-time AI feedback on students' essay drafts using Next.js on Vercel and Supabase in the Seoul region. Three days before their public launch, they hosted a mock essay contest in which 500 students submitted essays simultaneously and received streaming AI critiques.

Within three minutes of starting the load test, two critical failures surfaced. First, a security engineer inspecting the page source and the compiled JavaScript chunks under _next/static/chunks/ in browser DevTools discovered a variable named NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY containing the live service_role JWT in plaintext. When a junior developer had run into an RLS permission error while building a client-side score aggregation widget, they had prefixed the service_role key with NEXT_PUBLIC_ so the browser could run the query directly. Second, as soon as 150 students clicked the critique button at the same time, response latency stretched past 10 seconds and more than half of the requests failed with HTTP 500 errors (too many clients already and FUNCTION_INVOCATION_TIMEOUT).

The infrastructure lead immediately overhauled the configuration:

  1. Key rotation and boundary restoration: Because the service_role key had already been shipped in a public client bundle, it was treated as compromised and immediately rotated (regenerated) in the Supabase dashboard. Only NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY were allowed to keep the public prefix; the score aggregation logic was moved into a server-only Route Handler backed by least-privilege RLS policies rather than service_role.
  2. Function region and execution duration tuning: Inspection revealed that Vercel functions were still running in the default US East region (iad1), crossing the Pacific Ocean on every database query to Seoul. The team added "regions": ["icn1"] to vercel.json to colocate the functions with the Seoul database, configured export const maxDuration = 60; on the streaming critique route, and enabled Fluid Compute.
  3. Switching to Supavisor Transaction Mode: The runtime DATABASE_URL had been pointing to port 5432 (Session Mode), causing concurrent serverless functions to exhaust the connection pool almost immediately. The team switched the runtime DATABASE_URL to port 6543 (Supavisor Transaction Mode) with prepared statements disabled in the ORM, while isolating schema migration scripts onto a separate DIRECT_URL using port 5432.

When the team re-ran the 500-student load test the next day, active backend PostgreSQL connections stayed steady around 15, and the average Time to First Byte (TTFB) for the first streaming critique sentence dropped from 4.2 seconds to 0.8 seconds.

Cheongryu Bio: Preventing private research note leaks with RLS WITH CHECK and signed upload URLs

Cheongryu Bio operates a multi-tenant B2B SaaS platform that uses AI to analyze pharmaceutical research literature, giving each research laboratory an isolated workspace. Researchers upload experimental result PDFs (up to 30 MB each) and write summary notes. Shortly after onboarding pilot customers, two urgent issues were reported.

First, whenever a researcher tried to upload a PDF larger than 10 MB, the Vercel API route failed immediately with 413 Payload Too Large. The client was sending the entire file payload through a Vercel serverless function, which then forwarded it to Supabase Storage—hitting Vercel's function request body size limit. Second, during an internal penetration test, an engineer signed in with a Lab A account opened the browser console and ran supabase.from('notes').update({ lab_id: 'lab_B' }).eq('id', myNoteId), discovering that they could push their note directly into Lab B's workspace feed.

The platform team diagnosed the root causes and redesigned the data and storage layers as follows.

Problem area Flaw in the original design Hardened architecture
notes table RLS policies The UPDATE policy only checked USING (lab_id = current_user_lab()) without enforcing tenant immutability, or INSERT used WITH CHECK (true), allowing cross-tenant lab_id injection Explicitly enforced WITH CHECK (lab_id = current_user_lab()) on both INSERT and UPDATE, plus a database trigger blocking any change to lab_id after creation
Large PDF uploads (up to 30 MB) Browser → Vercel serverless function (4.5 MB body cap) → Supabase Storage relay Server verifies tenant membership and mints a one-time createSignedUploadUrl → browser uploads directly to the private Supabase Storage bucket
Viewing research PDFs Storage bucket was set to Public for convenience, relying on unguessable file paths Converted the bucket to Private, with the server verifying lab membership before issuing a 60-second signed download URL (createSignedUrl)

Early in the discussion, someone suggested making the Storage bucket Public so browsers could upload directly without server coordination, but the team rejected that proposal immediately due to the risk of leaking proprietary drug-discovery data. Combining a Private Bucket with server-issued signed upload and download URLs bypassed the serverless payload size limit while preserving strict tenant isolation.

Common mistakes

  1. "NEXT_PUBLIC_ environment variables are stored encrypted in the Vercel dashboard, so secrets placed there remain safe in the browser." Encryption in the dashboard only protects the variable while stored at rest before a build starts. The moment your application builds, any variable prefixed with NEXT_PUBLIC_ is inlined as a literal plaintext string inside the compiled JavaScript bundle downloaded by every visitor's browser. Never use the NEXT_PUBLIC_ prefix for secret API keys or database credentials.
  2. "I updated a NEXT_PUBLIC_ variable in the Vercel dashboard, so the live site reflects the new value immediately." Unlike server-only environment variables that are read dynamically at request time, variables inlined into client bundles are frozen at build time and cached as immutable static files on the CDN. After changing a NEXT_PUBLIC_ variable, you must trigger a fresh redeploy (a new build) for browsers to receive the updated value.
  3. "Deploying serverless functions to every region around the world simultaneously, just like a CDN, always makes an app faster." When a serverless function executes multiple sequential queries against a single-region primary database (for example, in Seoul), running that function in a distant region multiplies the cross-ocean network round-trip time (RTT) by the number of sequential queries and connection handshakes. Functions that talk to a primary database must be pinned to the region closest to that database (icn1).
  4. "Port 5432 is the standard PostgreSQL port, so serverless applications should connect on port 5432 at runtime." In serverless environments where ephemeral function instances scale out rapidly during traffic bursts, Session Mode or direct connections on port 5432 hold backend PostgreSQL processes open until disconnect and quickly exhaust max_connections. Runtime application traffic must use Supavisor Transaction Mode on port 6543, reserving port 5432 (DIRECT_URL) exclusively for schema migrations.
  5. "As long as I write a strict SELECT RLS policy on a table, other users cannot modify or delete my data." PostgreSQL evaluates Row Level Security (RLS) policies separately for each SQL command (SELECT, INSERT, UPDATE, DELETE). A SELECT policy governs read visibility only; if you configure INSERT or UPDATE policies with a permissive WITH CHECK (true), attackers can insert or overwrite rows spoofing another user's or tenant's identifier.
  6. "If I name files with long random UUIDs that are impossible to guess, it is safe to store confidential customer documents in a Public bucket." Obscure URLs (security by obscurity) do not constitute access control. Files inside a Public bucket require zero authentication to download; if a link leaks once via browser history, HTTP Referer headers, or shared logs, it can never be revoked. Confidential files must always reside in a Private bucket and be accessed through short-lived signed URLs.

Summary at a glance

Core topic Mandatory design principle to remember
Environment separation Strictly isolate databases, storage buckets, and external API keys between Preview and Production; inject live production secrets into the Production environment only
NEXT_PUBLIC_ boundary Inlined as literal plaintext into client JavaScript bundles at build time. Never use for secrets or service_role keys, and always trigger a redeploy after changing values
Deployment Protection & bypass Lock Preview URLs behind Vercel Authentication, and allow automated CI E2E test suites and webhooks to pass using the x-vercel-protection-bypass header
Function region & execution Colocate serverless functions with your primary DB region (such as Seoul icn1), and configure maxDuration, response streaming, and Fluid Compute for slow LLM calls
Supavisor 6543 vs. 5432 Use Transaction Mode (port 6543, with prepared statements disabled) for serverless runtime queries, and Session/Direct connections (port 5432) for schema migrations
RLS USING vs. WITH CHECK USING filters existing rows for SELECT, UPDATE, and DELETE, whereas WITH CHECK validates the new or resulting row state produced by INSERT or UPDATE
anon vs. service_role The anon key is public by design and relies on RLS for security; service_role bypasses all RLS (BYPASSRLS) and must be isolated to minimal backend-only paths
Auth redirect allowlists Maintain explicit redirect URL allowlists for web origins and native mobile deep links (myapp://...) with PKCE to prevent open redirects and authorization token theft
Storage access control Store confidential files in Private buckets (public = false), and issue short-lived signed URLs (createSignedUrl, createSignedUploadUrl) for viewing and direct uploads

Check the official docs

Self-check

1-1. Explain how environment variables with and without the NEXT_PUBLIC_ prefix are handled differently at build time and runtime when deploying a Next.js application to Vercel, and state what action you must take after updating NEXT_PUBLIC_API_BASE in the dashboard so the change reaches users' browsers.

1-2. In a web service whose primary database (Supabase Postgres) is located in the Seoul region and whose main user base is in Korea, explain from a network round-trip time (RTT) perspective why leaving the Vercel serverless function region at its default US East (iad1) setting causes severe latency amplification within a single HTTP request.

1-3. Compare when Supavisor Transaction Mode (port 6543) and Session Mode (port 5432) borrow and return backend PostgreSQL connections, and explain with reasons which mode should be used for serverless runtime queries versus database schema migrations.

1-4. Distinguish when the USING clause and the WITH CHECK clause are evaluated in PostgreSQL Row Level Security (RLS), and give a concrete example of a security vulnerability that can occur if an UPDATE policy specifies a correct USING expression but leaves WITH CHECK overly permissive.

1-5. When Vercel Deployment Protection is enabled on Preview deployments, write the two HTTP request headers that allow an automated E2E test script running in GitHub Actions to navigate and test multiple pages across a Preview URL without turning off Deployment Protection.

1-6. Design task: You need to store user-uploaded personal contract PDFs (up to 25 MB each) securely so that only the uploading user can view them, while avoiding Vercel's serverless function request body size limit. Describe how you should design (1) the Supabase Storage bucket visibility setting, (2) the upload flow, and (3) the viewing/download flow.

Hands-on tasks

Exercise

Build an environment variable and connection string security audit table (40 minutes)

  1. List 10 environment variables for an AI web service you operate or design (for example: NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, DATABASE_URL, DIRECT_URL, OPENAI_API_KEY, STRIPE_WEBHOOK_SECRET, VERCEL_AUTOMATION_BYPASS_SECRET, SESSION_SECRET).
  2. Fill out five columns for each variable in a table: (a) whether it is included in the client bundle, (b) whether Preview and Production must use different values, (c) the blast radius / damage scenario if leaked, (d) the valid execution caller (browser / server Route Handler / migration CLI / CI runner), and (e) whether changing the value requires a fresh build/redeploy.
  3. Specify the exact port numbers (6543 vs. 5432) and query parameters (such as ?pgbouncer=true) required for DATABASE_URL and DIRECT_URL.

Completion criteria: The table clearly separates the 3 variables safe for client bundles from the 7 server-only secrets that must never reach the browser, and separates the runtime pooler connection from the migration connection by both port and purpose.

Exercise

Design multi-tenant RLS policies and a private Storage signed-URL flow (50 minutes)

  1. Write the SQL statement to define a projects (id, org_id, owner_id, title, status) table and enable RLS (ENABLE ROW LEVEL SECURITY).
  2. Write four separate RLS policies targeting the authenticated role for SELECT, INSERT, UPDATE, and DELETE. Wrap auth.uid() in a scalar subquery (SELECT auth.uid()), and specify both USING and WITH CHECK on the UPDATE policy so a user cannot tamper with owner_id or org_id to transfer a row to another user or organization.
  3. Write out the four-step sequence showing what your server API verifies before calling createSignedUploadUrl and createSignedUrl (and with what expiration window in seconds) when a user uploads or downloads a file in the private project-files bucket.

Completion criteria: All four SQL policies are syntactically valid, WITH CHECK prevents spoofing another user's owner_id or org_id on both INSERT and UPDATE, and both uploads and downloads on the private bucket are governed by short-lived signed URLs.