To secure a Cursor + Neon + Railway stack, you need to: (1) give each environment its own Neon branch, and remember that each branch has its own ep- hostname, (2) store connection strings as Railway variables, never in railway.toml or the repo, (3) connect your app with a role you created in SQL, because the default neondb_owner role bypasses row-level security, (4) set per-request RLS context inside a transaction, since Neon's pooler drops session settings, and (5) add a .cursorignore so Cursor's AI features don't read your .env files.
TL;DR
The most common mistake in this stack is enabling Postgres row-level security and then connecting as neondb_owner. That role inherits BYPASSRLS from neon_superuser, so your policies never run. Create an app role, connect Railway with it, set the user context with set_config(..., true) inside a transaction, and give staging its own Neon branch rather than a different database name on the production hostname.
Before you start, keep these open: the Cursor security guide, the Neon security guide, the Railway security guide, and the Railway security checklist.
Stack Overview
| Component | Role | Security Focus |
|---|---|---|
| Cursor | AI code editor | Query safety, keeping .env out of AI context |
| Neon | Serverless Postgres | Branch isolation, app roles, RLS, TLS |
| Railway | Hosting platform | Variables per environment, service isolation |
Part 1: One Neon Branch per Environment
Neon branches are instant copies of a parent branch: schema and data. That's what makes them good for isolation, and it's also the catch. A preview branch cut from production holds every row of production, including user emails and anything else you store.
main production data, mark it protected
development staging, branched from main
preview/feature-123 per-PR, short-lived, delete when the PR closes
If preview branches are going to strangers (contractors, a public preview URL), branch them from development or from a branch you have scrubbed, not from main. Neon's protected branches (paid plans) also help here: roles on a branch created from a protected branch get fresh passwords, so a leaked preview credential doesn't open production.
Each branch has its own hostname
This one is easy to get wrong because AI-written config often gets it wrong. You don't switch environments by changing the database name at the end of the URL. Each branch is reached through its own compute, and each compute has its own ep- hostname. The database name (usually neondb) is the same on every branch.
# Railway production environment
DATABASE_URL="postgresql://app_user:...@ep-aaa-111-pooler.us-east-2.aws.neon.tech/neondb?sslmode=verify-full"
# Railway staging environment (different Neon branch, different ep- host)
DATABASE_URL="postgresql://app_user:...@ep-bbb-222-pooler.us-east-2.aws.neon.tech/neondb?sslmode=verify-full"
A URL that keeps the production hostname and swaps /neondb for /development doesn't point at your staging branch. It points at a database called development on the production branch, if one exists, and fails to connect if it doesn't.
Neon rejects connections that don't use TLS, so you can't accidentally run unencrypted. Neon recommends sslmode=verify-full, which also checks the hostname on the certificate. Add channel_binding=require if your driver supports it.
Part 2: Row-Level Security That Actually Runs
This is the section the original version of this page got wrong, and it's the one most worth reading. We tested every step below on Postgres 16.
Problem 1: the default role bypasses RLS
Neon's docs say roles created in the Neon Console, including the one created with every project, are members of neon_superuser, and that neon_superuser has BYPASSRLS in projects created after August 15, 2023. The connection string on your Neon dashboard uses that role.
So you can enable RLS, write careful policies, and see every row anyway. Here's what our test showed with the same two-row table and the same policy:
| Connected as | Rows visible (should be 1) |
|---|---|
A role with BYPASSRLS (like neondb_owner) | 2 |
| The table owner, RLS enabled but not forced | 2 |
The table owner, after FORCE ROW LEVEL SECURITY | 1 |
A separate app role with only SELECT granted | 1 |
Two separate bypasses, then: BYPASSRLS skips policies outright, and table owners skip them unless you force it. Fix both by giving the app its own role.
-- Run as neondb_owner. A role created with SQL is NOT a member of neon_superuser.
CREATE ROLE app_user WITH LOGIN PASSWORD 'use-a-generated-password';
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON posts TO app_user;
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE posts FORCE ROW LEVEL SECURITY; -- owner obeys policies too
CREATE POLICY "own posts" ON posts FOR SELECT
USING (author_id = current_setting('app.user_id', true));
CREATE POLICY "published posts" ON posts FOR SELECT
USING (published = true);
CREATE POLICY "update own posts" ON posts FOR UPDATE
USING (author_id = current_setting('app.user_id', true));
Put app_user's connection string in Railway's DATABASE_URL. Keep the neondb_owner string out of the app entirely and use it only for migrations.
auth.uid() is a Supabase function, not Postgres. Cursor often writes policies with auth.uid() because most RLS examples online are Supabase examples. On Neon, CREATE POLICY ... USING (auth.uid() = author_id) fails with schema "auth" does not exist. Use current_setting('app.user_id', true) (or whatever setting name you pick) instead.
Problem 2: setting the user context
The snippet that usually gets generated for this looks like client.query('SET LOCAL auth.uid = $1', [userId]). It fails twice. SET can't take a bind parameter, so Postgres returns syntax error at or near "$1". And SET LOCAL outside a transaction only prints a warning and does nothing.
Neon's pooler adds a third trap. It runs PgBouncer in transaction mode, so a plain session SET can land on a connection that the next request reuses, or vanish before your query runs. The safe pattern is set_config with is_local = true, inside a transaction, on the same client:
import { Pool } from '@neondatabase/serverless';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function queryAsUser(userId: string, sql: string, params: unknown[]) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// is_local = true scopes the setting to this transaction only
await client.query("SELECT set_config('app.user_id', $1, true)", [userId]);
const result = await client.query(sql, params);
await client.query('COMMIT');
return result.rows;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
If the context never gets set, current_setting('app.user_id', true) returns NULL and the "own posts" policy matches nothing. In our test the app role saw 0 rows with no context set. That's the failure you want: a bug shows up as empty results, not as someone else's data.
Part 3: Railway Variables and Service Isolation
Store both Neon URLs as Railway variables, scoped per environment, and nothing in railway.toml. That file is committed to your repo, so anything in it is in git history forever.
[deploy]
healthcheckPath = "/health"
healthcheckTimeout = 300
# No secrets here. DATABASE_URL lives in Railway variables.
If several services need the same Neon URL, define it once as a shared variable and pull it into each service with a reference like ${{shared.DATABASE_URL}}. How Railway reference variables work covers the syntax, and the Railway environment variables guide covers scoping and the NEXT_PUBLIC_ trap that ships a variable to the browser.
Railway project
web (frontend)
NEXT_PUBLIC_API_URL public, fine in the browser
api (backend)
DATABASE_URL app_user, pooler host
JWT_SECRET
worker (background jobs)
DATABASE_URL its own role if it needs different grants
migrations (one-off job)
DIRECT_URL neondb_owner, direct host, never in web or api
Only the migration job gets the owner credential. If the api service is ever compromised, it holds a role that RLS still applies to.
Part 4: Pooler vs Direct Connection
# Direct connection (migrations, long-running admin work)
DIRECT_URL="postgresql://neondb_owner:...@ep-aaa-111.us-east-2.aws.neon.tech/neondb?sslmode=verify-full"
# Pooler connection (the app)
DATABASE_URL="postgresql://app_user:...@ep-aaa-111-pooler.us-east-2.aws.neon.tech/neondb?sslmode=verify-full"
# The only difference in the host is "-pooler"
Part 5: Keep Cursor Away From the Credentials
Add a .cursorignore at the repo root listing .env, .env.* and any file with a connection string. Cursor's docs describe this as best-effort, so it reduces what the AI reads rather than guaranteeing it. The real protection is not having the owner credential on your laptop's .env at all: pull it from Railway or Neon only when you run a migration.
Review any SQL Cursor writes against the two RLS problems above. If you see auth.uid(), SET LOCAL ... = $1, or a DATABASE_URL with neondb_owner in it, stop and fix it.
Security Checklist
Pre-Launch Checklist for Cursor + Neon + Railway
Alternative Stacks
- Cursor + Supabase + Vercel: Postgres with RLS too, but with built-in auth, so
auth.uid()is real there. - Cursor + PlanetScale + Vercel: a similar branching model on MySQL, with no RLS.
- Cursor + MongoDB + Railway: the same Railway hosting with a document database.
Does row-level security work with the default Neon connection string?
No. The role in the connection string Neon gives you (neondb_owner) is a member of neon_superuser, which has BYPASSRLS in projects created after August 15, 2023. Every policy is skipped for that role. Create a separate role with SQL for your app and connect Railway with that one.
How does Neon branching differ from PlanetScale?
Both offer database branching, but Neon is Postgres with full SQL support including foreign keys and row-level security. PlanetScale's MySQL product handles relations differently and has no RLS. Either way, a branch copies its parent's data, so a preview branch off production holds production data.
When should I use the Neon connection pooler?
Use the -pooler hostname for your Railway app, especially anything that opens many short-lived connections. Use the direct hostname for migrations. The pooler runs in transaction mode, so session-level SET commands do not persist between transactions; set per-request context inside a transaction instead.
Can I use Prisma with Neon on Railway?
Yes. Put the pooler endpoint in DATABASE_URL and the direct endpoint in DIRECT_URL for migrations, both as Railway variables. Prisma connects as whatever role the URL names, so the BYPASSRLS warning applies to it too.
Using Neon with Railway?
Scan your app for connection and RLS security issues.