Overview
Row-Level Security (RLS) ensures users can only access data they are authorized to see. In API routes and server actions, RLS context is set automatically. Background jobs and seeds require explicit bypass.
What it is
Database-level security that filters queries based on authenticated user context. Every query automatically scoped to authorized data.
Why we use it
Defense in depth - even if application code has bugs, users cannot access unauthorized data. Prevents IDOR vulnerabilities at the database layer.
When to use
Automatic in API routes and server actions. Only use withRLSBypass in background jobs and seed scripts.
Context by Layer
| Layer | RLS Context | Pattern |
|---|---|---|
| API Routes | Automatic | via withApiLogging() |
| Server Actions | Automatic | via withServerAction() |
| Background Jobs | Manual | withRLSBypass() required |
| Seeds | Manual | withRLSBypass() required |
| React Components | N/A | No direct DB access |
Quick Start
API Routes (Automatic)
RLS is automatic in API routes - just query.
// API Routes - RLS context is AUTOMATIC
import { prisma } from '@/lib/db';
// RLS context set automatically by withApiLogging()
const programs = await prisma.program.findMany();
// Even raw SQL is filtered by RLS
const raw = await prisma.$queryRaw`SELECT * FROM "Program"`;Patterns
Server Actions
Use withServerAction wrapper for RLS context in server actions.
'use server';
import { withServerAction } from '@/lib/auth/server';
export async function getDataAction(programSlug: string) {
return withServerAction({ programSlug }, async (ctx) => {
// ctx.userId available, RLS context set automatically
const data = await services.getData();
return { success: true, data };
});
}Background Jobs
Background jobs have no user context - explicit bypass required.
// Background Jobs - Manual bypass REQUIRED
import { defineJob, withRLSBypass } from '@/lib/jobs';
export const myJob = defineJob(
{ id: 'my-job' },
{ event: 'app/event' },
async ({ event }) => {
// Wrap all DB operations in withRLSBypass
await withRLSBypass('job:my-job', async () => {
await prisma.entity.update({
where: { id: event.data.entityId },
data: { status: 'processed' },
});
});
}
);Seeds
Seed scripts need bypass for initial data.
// Seeds - Manual bypass REQUIRED
import { withRLSBypass } from '@/lib/db';
async function seed() {
await withRLSBypass('seed:data', async () => {
await prisma.program.create({
data: { name: 'Demo Program', slug: 'demo' },
});
});
}Watch Out
Never use withRLSBypass in API routes (ESLint enforced)
Don't
// WRONG - ESLint blocks this in API routes
import { withRLSBypass } from '@/lib/db';
export async function GET() {
// This will fail ESLint - bypass not allowed in routes
await withRLSBypass('route:bypass', async () => {
const data = await prisma.secret.findMany();
return NextResponse.json({ data });
});
}Do
// RIGHT - Let RLS filter automatically
import { prisma } from '@/lib/db';
export async function GET() {
// RLS automatically filters to user's authorized data
const programs = await prisma.program.findMany();
return NextResponse.json({ ok: true, data: programs });
}Background jobs MUST use withRLSBypass
Don't
// WRONG - Will fail RLS, no user context
export const processJob = defineJob(
{ id: 'process-job' },
{ event: 'app/process' },
async ({ event }) => {
// No user context = RLS denies all queries
await prisma.entity.update({
where: { id: event.data.id },
data: { processed: true },
});
}
);Do
// RIGHT - Explicit bypass with audit label
import { defineJob, withRLSBypass } from '@/lib/jobs';
export const processJob = defineJob(
{ id: 'process-job' },
{ event: 'app/process' },
async ({ event }) => {
await withRLSBypass('job:process-job', async () => {
await prisma.entity.update({
where: { id: event.data.id },
data: { processed: true },
});
});
}
);- Import withRLSBypass from @/lib/jobs in Inngest functions, not @/lib/db
- Always include a descriptive label for audit trail (job:name or seed:name)