Remix + PlanetScale security — MySQL-at-scale architecture

Remix + PlanetScale (or any Vitess-backed MySQL) is a safer choice than self-hosted MySQL and more scalable than SQLite-class stacks. PlanetScale enforces TLS by default, handles connection pooling at their proxy layer, and the branch-based schema workflow prevents production-level DDL accidents. The application-side security work is mostly standard: parameterize every query, authenticate every loader and action, and scope queries by user. The Remix-specific pieces are the same as any Remix app: loader/action auth at the top of every function, CSRF/origin check on mutations, and session cookie with proper flags. The PlanetScale-specific gotcha is that the database connection URL and password are not the only credential — PlanetScale can also issue per-branch `PASSWORD` tokens and per-role access (read-only vs read-write). Use the narrowest-scope credential per environment. Prisma is the common ORM for this stack. Avoid `$queryRawUnsafe`; prefer `$queryRaw` with template literals (Prisma parameterizes), or use the generated client. Drizzle and Kysely both support MySQL with parameter bindings.

What breaks on this stack

SQL injection via Prisma raw queries

`prisma.$queryRawUnsafe('select ... ' + id)` reintroduces injection. Use `$queryRaw` with template literals (Prisma parameterizes automatically) or explicit parameters.

Read the guide →

Missing auth in Remix loader

Same as any Remix app — every loader needs an auth check at the top or is explicitly public.

Over-privileged PlanetScale password

PlanetScale issues DB passwords with different access levels (read-only vs read-write) and branch scoping. Use read-only passwords for read-path services, read-write for mutations.

Missing TLS verification

PlanetScale requires TLS; your connection string should include ?ssl_mode=VERIFY_IDENTITY or equivalent. Default prisma/node setups may skip CA verification.

Branch-production drift

Schema-changes applied to a dev branch that never merged to main can cause staging/prod mismatches. Keep DDL in version control and apply via PlanetScale's deploy-request workflow.

Pre-ship checklist

  • All queries parameterized ($queryRaw with literals, not $queryRawUnsafe)
  • Every Remix loader and action has auth as first line
  • PlanetScale password scoped per environment and service (read vs write)
  • Connection string uses ?ssl_mode=VERIFY_IDENTITY
  • Migrations applied via PlanetScale deploy-request (not raw ALTER TABLE)
  • Prisma Client regenerated after every schema change in CI
  • Audit log table tracks every INSERT / UPDATE / DELETE with user attribution
  • Row-level ownership enforced in every query (user_id filter)
  • Connection pool size tuned for PlanetScale's per-branch connection limit
  • Backup and restore drill runs quarterly

Starter config

# .env.production — PlanetScale connection with TLS verification
DATABASE_URL=mysql://read_write_user:***@aws.connect.psdb.cloud/appdb?ssl={"rejectUnauthorized":true}
DATABASE_URL_READONLY=mysql://readonly_user:***@aws.connect.psdb.cloud/appdb?ssl={"rejectUnauthorized":true}

// prisma/schema.prisma
generator client { provider = "prisma-client-js" }
datasource db {
  provider     = "mysql"
  url          = env("DATABASE_URL")
  relationMode = "prisma" // PlanetScale required
}

// app/routes/orders.$id.tsx
import { redirect } from "@remix-run/node";
export async function loader({ request, params }: LoaderFunctionArgs) {
  const userId = await requireUser(request);
  const order = await db.order.findFirst({
    where: { id: params.id, userId }, // user scoping enforced
  });
  if (!order) throw new Response("not found", { status: 404 });
  return json({ order });
}