hackymacky

Notes

← Blog

Is your Neon connection string in your Next.js bundle?

15 Oct 2026 · 1693 words

A Neon DATABASE_URL is a role and a password. How it lands in a Vercel client bundle, how to grep .next/static, and how to rotate the role without dumping rows.

A Neon connection string is not a publishable key. It is a Postgres role, that role's password, a host, and a database name, in one URL. If that URL is in JavaScript the browser downloads, anyone who can open your site can open a session as that role.

Supabase's anon key is supposed to be in the client. The damage there is missing RLS, which is what the RLS post is about. A Neon DATABASE_URL is the other shape: the secret itself shipped. This note is how that happens on Vercel, how to see it in the build output, and how to rotate the role. The Data API is a second HTTP door. A password in the bundle does not need it.

What the URL contains

Neon documents the connection string as a normal Postgres URI. The host is the compute endpoint and starts with ep-. Pooled and direct are the same string with one difference: the pooled host adds -pooler to that endpoint id.

text

postgresql://role:password@ep-example-123456-pooler.us-east-2.aws.neon.tech/dbname?sslmode=require
postgresql://role:password@ep-example-123456.us-east-2.aws.neon.tech/dbname?sslmode=require

The user is the role. The password is that role's password. The path is the database. Connect-modal strings include sslmode=require, and current examples also add channel_binding=require. The password above is a stand-in. The host you copy is *.neon.tech. Some SDK examples use *.neon.build. Either way the password sits between the role's colon and @.

Frameworks are told to store this as DATABASE_URL and pass it to a driver. That is the correct server-side use. The name of the variable does not make the password safe. Keeping it off the browser does.

How Next.js puts it in the bundle

Next.js keeps environment variables in Node. They reach the browser only with a NEXT_PUBLIC_ prefix. At next build that prefix is replaced with the value from the build environment and written into client JS. A finished build does not pick up later edits. Preview and production freeze whatever each build saw. The rest of that mechanism is in the client-bundle post.

Three paths inline a Neon URL.

NEXT_PUBLIC_DATABASE_URL, NEXT_PUBLIC_POSTGRES_URL, or the same with VITE_ / EXPO_PUBLIC_. The prefix means "ship this." An empty DATABASE_URL in a client component is the unprefixed variable staying on the server. Stop reading it on the client.

A literal. neon("postgresql://…@ep-….neon.tech/neondb") imported by a Client Component is source text. It is in the chunk.

The env key in next.config. Those values are always bundled. NEXT_PUBLIC_ does not apply to that key; it only applies to .env files. env: { DATABASE_URL: process.env.DATABASE_URL } bakes the URL in when the config loads. process.env[name] is not inlined. A bare process.env.DATABASE_URL in a Client Component, with no prefix and no env entry, stays empty on the client. The leak is the prefix, the literal, or that config key.

@neondatabase/serverless is usually what gets imported. neon(process.env.DATABASE_URL) uses HTTP. Pool and Client use WebSockets. Neon documents the driver for serverless and edge, where TCP fits poorly, and tells you to read DATABASE_URL from the environment. Those are still servers. Do not put the driver in a "use client" file. Middleware and Route Handlers can use it while the string stays server-side. The break is one lib/db.ts imported from both a Server Component and a Client Component, or neon() pointed at a NEXT_PUBLIC_ variable.

Vercel scopes each variable to Production, Preview, Development, and any custom environment. Preview covers non-production branches and can be limited to one branch. A NEXT_PUBLIC_ value on Preview is inlined into that deployment. Server-only DATABASE_URL on the environments that run server code is the setup you want. A public-prefixed copy on any scope is the bug.

Look at the files you ship

After next build:

bash

rg -n "postgres(ql)?://|neon\\.tech|neon\\.build" .next/static

A hit shaped like postgresql://ROLE:PASSWORD@ep-….neon.tech/DB is the leak. Replace the password with [redacted] before it goes into a ticket. The host and the role are enough to rotate.

On a live deployment, list the chunks and search those bodies:

bash

curl -sS https://your-app.vercel.app | rg -o '/_next/static/[^" ]+\.js'

Download those paths and search them the same way. The password is the text after the role's colon and before @. Once that response was public, the URL is public.

Search source as well:

bash

rg -n "NEXT_PUBLIC_.*(DATABASE|POSTGRES|NEON)|neon\\(" app src next.config.ts next.config.js

neon( under "use client", or in a file a client module imports, is the review. The argument should not be a public env var or a literal URL.

Rotate the role, then remove the string

Reset the password, then update every place the old string was stored. In the Console: pick the branch, then Postgres database → Roles → Reset password. That generates a password. There is no CLI command for the reset. The API is POST /projects/{project_id}/branches/{branch_id}/roles/{role_name}/reset_password. To choose the value yourself, the SQL Editor accepts:

sql

ALTER USER user_name WITH PASSWORD 'new_password';

Use the role from the leaked URL. A database named neondb starts with neondb_owner. Neon wants about 60 bits of entropy: at least 12 characters, mixed case, a digit, and a symbol. Do not reuse the sample password from the connection docs.

A reset takes effect immediately. The old password stops working on the next connection, after the reset operation finishes. Roles are branch-scoped. A child branch copies parent roles, so reset every branch that had the leaked password. Put the new string from the Connect modal into Vercel, CI, and .env files.

Do not connect with the password from the bundle to confirm it. Rotate, deploy a bundle without the URL, and rg for the old host.

Console, CLI, and API roles — including the project's owner role — are members of neon_superuser. Projects created after 15 August 2023 include BYPASSRLS on that role, plus broad read and write. A leaked owner URL is not one table. SQL-created roles are not in neon_superuser. After rotating, prefer one of those for the app if DATABASE_URL was the owner. A role that still owns the database cannot be dropped.

NEON_AUTH_COOKIE_SECRET is a different value and the same mistake. Next.js Managed Better Auth wants NEON_AUTH_BASE_URL and that cookie secret. The base URL is client configuration. The secret signs the session cookie and must be at least 32 characters. It stays off NEXT_PUBLIC_. If the name and a real value are in a chunk, replace it (openssl rand -base64 32), keep it server-only, and redeploy. Cookies signed with the old secret will not verify. The Auth URL itself (…neonauth…/dbname/auth) is what createAuthClient is documented to take. Shipping that URL is not shipping the password.

The Data API is the other door

The Neon Data API is PostgREST-compatible HTTP. Neon says it has no permission layer of its own: GRANT and Row Level Security decide. The URL looks like https://ep-….apirest.<region>.aws.neon.tech/<database>/rest/v1. @neondatabase/neon-js derives that from a credential-free database URL by inserting the apirest label after the endpoint id and appending /rest/v1. A cell label such as c-2, when present, stays. The URL you pass to createClient is https:// with no user and no password. That is intentional. It is not DATABASE_URL.

Requests with no Authorization header run as anonymous (db_anon_role, default anonymous). That role starts with no privileges. A valid JWT runs as authenticated, or as the role in the token's role claim. RLS then filters. Neon describes three states: RLS off means a granted role sees every row; RLS on with no policy returns no rows; RLS on with policies returns what the policy allows. auth.user_id() reads the JWT sub claim. The console warns when RLS is off.

Check an endpoint you already ship. No writes:

bash

curl -sS -D - \
  'https://ep-xxx.apirest.us-east-1.aws.neon.tech/neondb/rest/v1/posts?select=id&limit=1' \
  -H 'Accept: application/json' \
  -H 'Range: 0-0' \
  -H 'Prefer: count=exact' \
  -o /tmp/neon-body.txt

No password. No Authorization. Read the status and Content-Range, then delete the body file. 401 or 403 means this call was refused. 200 or 206 with a total above zero means anonymous could see rows: a grant, and either RLS off or a policy that allows it. Total 0 or a body of [] means the SELECT was accepted and returned nothing. That is also an empty table with RLS off. An empty array is not proof the table is locked. Confirm RLS on the Data API page in the console.

OpenAPI, if enabled, is {data-api-url}/openapi.json. Neon documents it as disabled by default and as requiring a JWT. A schema with no token means turn OpenAPI specification back to Disabled.

To close anonymous, use a restrictive policy so a later permissive one cannot reopen the table. Use Neon's role name anonymous, not Supabase's anon, and only a table you actually saw:

sql

ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY;

CREATE POLICY "posts_deny_anonymous"
ON public.posts
AS RESTRICTIVE
FOR ALL
TO anonymous
USING (false)
WITH CHECK (false);

Then REVOKE ALL ON TABLE public.posts FROM anonymous;. For authenticated, enable RLS and write policies on columns the table has. Do not invent user_id if it is not there. The RLS post is the same grant-then-policy order for Supabase's anon role. The role name differs. A public HTTP API in front of a table with RLS off does not.

Do not POST, PATCH, or DELETE to prove it, and do not sign up to mint a JWT. A read with no token is the check. Writes and new users make it worse.

What a scan of the live URL can see

hackymacky fetches the HTML and scripts a URL already serves. It flags postgres:// and postgresql:// URLs that carry a password on an ep-*.neon.tech or ep-*.neon.build host, pooled or direct. That finding is critical. The stored preview replaces the password with [redacted]. The password is not kept, and the scan does not connect with it. A real NEON_AUTH_COOKIE_SECRET assignment in that JavaScript is the same kind of finding. Placeholder passwords and the sample hosts from Neon's docs are skipped.

If the bundle contains a Data API URL, a credential-free Neon database URL, or a neonauth URL, the preview derives the Data API URL the way the SDK does and sends GETs with no Authorization header. It does not mint an anonymous token, create a user, or write. Rows, an OpenAPI document, or an accepted empty SELECT become a finding whose SQL enables RLS and denies anonymous with AS RESTRICTIVE. Supabase checks are unchanged.

The preview returns a score, severity counts, and three redacted titles. Claiming the domain unlocks the full list and the snippets. Paste a URL you may fetch, then rg the build yourself.