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 });
}