Cursor + Neon + Railway Security Blueprint

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.

Setup Time2-3 hours

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

ComponentRoleSecurity Focus
CursorAI code editorQuery safety, keeping .env out of AI context
NeonServerless PostgresBranch isolation, app roles, RLS, TLS
RailwayHosting platformVariables 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.

Recommended branch structure
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 variables, per environment
# 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 asRows visible (should be 1)
A role with BYPASSRLS (like neondb_owner)2
The table owner, RLS enabled but not forced2
The table owner, after FORCE ROW LEVEL SECURITY1
A separate app role with only SELECT granted1

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.

Create an app role and enable RLS
-- 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:

Setting user context for RLS (works with the pooler)
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.

railway.toml
[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.

Recommended Railway project structure
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

Two hostnames, two jobs
# 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

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.

Security Blueprints

Cursor + Neon + Railway Security Blueprint