CentercodePorygon
DittoPorygonAPI

Row-Level Security

Automatic data isolation at the database level

Safe

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

LayerRLS ContextPattern
API RoutesAutomaticvia withApiLogging()
Server ActionsAutomaticvia withServerAction()
Background JobsManualwithRLSBypass() required
SeedsManualwithRLSBypass() required
React ComponentsN/ANo 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)

Related

Authorization

Permission checking

Background Jobs

Inngest patterns

Prisma

Database access layer